├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── Builder.php ├── BuilderTrait.php ├── SoftDeletes.php ├── Versionable.php └── VersioningScope.php └── tests ├── Models ├── Post.php └── User.php ├── TestCase.php ├── Unit ├── BuilderTest.php ├── SoftDeletesTest.php └── VersionableTest.php └── database ├── factories ├── PostFactory.php └── UserFactory.php └── migrations ├── 2014_10_12_000000_create_users_table.php └── 2014_10_12_100000_create_posts_table.php /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | /vendor 3 | /coverage 4 | composer.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-present Markus Wetzel 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 all 13 | 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 THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Eloquent Versioning 2 | 3 | [![Latest Stable Version](https://poser.pugx.org/proai/eloquent-versioning/v/stable)](https://packagist.org/packages/proai/eloquent-versioning) [![Total Downloads](https://poser.pugx.org/proai/eloquent-versioning/downloads)](https://packagist.org/packages/proai/eloquent-versioning) [![Latest Unstable Version](https://poser.pugx.org/proai/eloquent-versioning/v/unstable)](https://packagist.org/packages/proai/eloquent-versioning) [![License](https://poser.pugx.org/proai/eloquent-versioning/license)](https://packagist.org/packages/proai/eloquent-versioning) 4 | 5 | This is an extension for the Eloquent ORM to support versioning. You can specify attributes as versioned. If an attribute is specified as versioned the value will be saved in a separate version table on each update. It is possible to use timestamps and soft deletes with this feature. 6 | 7 | ## Installation 8 | 9 | Eloquent Versioning is distributed as a composer package. So you first have to add the package to your `composer.json` file: 10 | 11 | ``` 12 | "proai/eloquent-versioning": "~1.0" 13 | ``` 14 | 15 | Then you have to run `composer update` to install the package. 16 | 17 | ## Example 18 | 19 | We assume that we want a simple user model. While the username should be fixed, the email and city should be versionable. Also timestamps and soft deletes should be versioned. The migrations would look like the following: 20 | 21 | ```php 22 | ... 23 | 24 | Schema::create('users', function(Blueprint $table) { 25 | $table->increments('id'); 26 | $table->integer('latest_version'); 27 | $table->string('username'); 28 | $table->timestamp('created_at'); 29 | }); 30 | 31 | Schema::create('users_version', function(Blueprint $table) { 32 | $table->integer('ref_id')->primary(); 33 | $table->integer('version')->primary(); 34 | $table->string('email'); 35 | $table->string('city'); 36 | $table->timestamp('updated_at'); 37 | $table->timestamp('deleted_at'); 38 | }); 39 | 40 | ... 41 | ``` 42 | 43 | The referring Eloquent model should include the code below: 44 | 45 | ```php 46 | Example: `User::version(2)->find(1)` will return version #2 of user #1 90 | 91 | * `allVersions()` returns all versions of the queried items
Example: `User::allVersions()->get()` will return all versions of all users 92 | 93 | * `moment(Carbon)` returns a specific version, closest but lower than the input date
Example: `User::moment(Carbon::now()->subWeek()->find(1)` will return the version at that point in time. 94 | 95 | #### Create, update and delete records 96 | 97 | All these operations can be performed normally. The package will automatically generate a version 1 on create, the next version on update and will remove all versions on delete. 98 | 99 | ### Timestamps 100 | 101 | You can use timestamps in two ways. For both you have to set `$timestamps = true;`. 102 | 103 | * Normal timestamps
The main table must include a `created_at` and a `updated_at` column. The `updated_at` column will be overriden on every update. So this is the normal use of Eloquent timestamps. 104 | 105 | * Versioned timestamps
If you add `updated_at` to your `$versioned` array, you need a `created_at` column in the main table and a `updated_at` column in the version table (see example). On update the `updated_at` value of the new version will be set to the current time. The `updated_at` values of previous versions will not be updated. This way you can track the dates of all updates. 106 | 107 | ### Soft Deletes 108 | 109 | If you use the `Versionable` trait with soft deletes, you have to use the `ProAI\Versioning\SoftDeletes` trait **from this package** instead of the Eloquent soft deletes trait. 110 | 111 | * Normal soft deletes
Just use a `deleted_at` column in the main table. Then on delete or on restore the `deleted_at` value will be updated. 112 | 113 | * Versioned soft deletes
If you create a `deleted_at` column in the version table and add `deleted_at` to the `$versioned` array, then on delete or on restore the `deleted_at` value of the new version will get updated (see example). The `deleted_at` values of previous versions will not be updated. This way you can track all soft deletes and restores. 114 | 115 | ## Custom Query Builder 116 | 117 | If you want to use a custom versioning query builder, you will have to build your own versioning trait, but that's pretty easy: 118 | 119 | ```php 120 | =7.1", 25 | "illuminate/database": "5.*" 26 | }, 27 | "require-dev": { 28 | "phpunit/phpunit": "^7.0", 29 | "mockery/mockery": "^1.0", 30 | "orchestra/testbench": "^3.6", 31 | "orchestra/database": "^3.6" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "ProAI\\Versioning\\": "src/" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "ProAI\\Versioning\\Tests\\": "tests/" 41 | } 42 | }, 43 | "extra": { 44 | "branch-alias": { 45 | "dev-master": "1.0-dev" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | ./tests 18 | 19 | 20 | 21 | 22 | ./src 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/Builder.php: -------------------------------------------------------------------------------- 1 | query->columns) 19 | ? array_merge($columns, $this->query->columns) 20 | : $columns; 21 | foreach($tempColumns as $column) { 22 | $segments = explode('.', $column); 23 | if ($segments[0] == $this->model->getTable()) { 24 | $this->query->addSelect($this->model->getVersionTable().'.*'); 25 | break; 26 | } 27 | } 28 | 29 | return parent::getModels($columns); 30 | } 31 | 32 | /** 33 | * Insert a new record into the database. 34 | * 35 | * @param array $values 36 | * @return bool 37 | */ 38 | public function insert(array $values) 39 | { 40 | // get version values & values 41 | $versionValues = $this->getVersionValues($values); 42 | $values = $this->getValues($values); 43 | 44 | // set version, ref_id and latest_version 45 | $values[$this->model->getLatestVersionColumn()] = 1; 46 | 47 | // insert main table record 48 | if (! $id = $this->query->insertGetId($values)) { 49 | return false; 50 | } 51 | 52 | $versionValues[$this->model->getVersionKeyName()] = $id; 53 | $versionValues[$this->model->getVersionColumn()] = 1; 54 | 55 | // insert version table record 56 | $db = $this->model->getConnection(); 57 | return $db->table($this->model->getVersionTable())->insert($versionValues); 58 | } 59 | 60 | /** 61 | * Insert a new record and get the value of the primary key. 62 | * 63 | * @param array $values 64 | * @param string $sequence 65 | * @return int 66 | */ 67 | public function insertGetId(array $values, $sequence = null) 68 | { 69 | // get version values & values 70 | $versionValues = $this->getVersionValues($values); 71 | $values = $this->getValues($values); 72 | 73 | // set version and latest_version 74 | $values[$this->model->getLatestVersionColumn()] = 1; 75 | $versionValues[$this->model->getVersionColumn()] = 1; 76 | 77 | // insert main table record 78 | if (! $id = $this->query->insertGetId($values, $sequence)) { 79 | return false; 80 | } 81 | 82 | // set ref_id 83 | $versionValues[$this->model->getVersionKeyName()] = $id; 84 | 85 | // insert version table record 86 | $db = $this->model->getConnection(); 87 | if (! $db->table($this->model->getVersionTable())->insert($versionValues)) { 88 | return false; 89 | } 90 | 91 | // fill the latest version value 92 | $this->model->{$this->model->getLatestVersionColumn()} = 1; 93 | 94 | return $id; 95 | } 96 | 97 | /** 98 | * Update a record in the database. 99 | * 100 | * @param array $values 101 | * @return int 102 | */ 103 | public function update(array $values) 104 | { 105 | // update timestamps 106 | $values = $this->addUpdatedAtColumn($values); 107 | 108 | // get version values & values 109 | $versionValues = $this->getVersionValues($values); 110 | $values = $this->getValues($values); 111 | 112 | // get records 113 | $affectedRecords = $this->getAffectedRecords(); 114 | 115 | // update main table records 116 | if (! $this->query->increment($this->model->getLatestVersionColumn(), 1, $values)) { 117 | return false; 118 | } 119 | 120 | // update version table records 121 | $db = $this->model->getConnection(); 122 | foreach ($affectedRecords as $record) { 123 | // get versioned values from record 124 | foreach($this->model->getVersionedAttributeNames() as $key) { 125 | $recordVersionValues[$key] = (isset($versionValues[$key])) ? $versionValues[$key] : $record->{$key}; 126 | } 127 | 128 | // merge versioned values from record and input 129 | $recordVersionValues = array_merge($recordVersionValues, $versionValues); 130 | 131 | // set version and ref_id 132 | $recordVersionValues[$this->model->getVersionKeyName()] = $record->{$this->model->getKeyName()}; 133 | $recordVersionValues[$this->model->getVersionColumn()] = $record->{$this->model->getLatestVersionColumn()}+1; 134 | 135 | // insert new version 136 | if(! $db->table($this->model->getVersionTable())->insert($recordVersionValues)) { 137 | return false; 138 | } 139 | } 140 | 141 | // fill the latest version value 142 | $this->model->{$this->model->getLatestVersionColumn()} += 1; 143 | 144 | return true; 145 | } 146 | 147 | /** 148 | * Delete a record from the database. 149 | * 150 | * @return mixed 151 | */ 152 | public function delete() 153 | { 154 | if (isset($this->onDelete)) { 155 | return call_user_func($this->onDelete, $this); 156 | } 157 | 158 | $this->forceDelete(); 159 | } 160 | 161 | /** 162 | * Run the default delete function on the builder. 163 | * 164 | * @return mixed 165 | */ 166 | public function forceDelete() 167 | { 168 | // get records 169 | $affectedRecords = $this->getAffectedRecords()->toArray(); 170 | $ids = array_map(function($record) { 171 | return $record->{$this->model->getKeyName()}; 172 | }, $affectedRecords); 173 | 174 | // delete main table records 175 | if (! $this->query->delete()) { 176 | return false; 177 | } 178 | 179 | // delete version table records 180 | $db = $this->model->getConnection(); 181 | return $db->table($this->model->getVersionTable()) 182 | ->whereIn($this->model->getVersionKeyName(), $ids) 183 | ->delete(); 184 | } 185 | 186 | /** 187 | * Get affected records. 188 | * 189 | * @return array 190 | */ 191 | protected function getAffectedRecords() 192 | { 193 | // model only 194 | if ($this->model->getKey()) { 195 | $records = [$this->model]; 196 | } 197 | 198 | // mass assignment 199 | else { 200 | $records = $this->query->get(); 201 | } 202 | 203 | return $records; 204 | } 205 | 206 | /** 207 | * Get affected ids. 208 | * 209 | * @param array $values 210 | * @return array 211 | */ 212 | protected function getValues(array $values) 213 | { 214 | $array = []; 215 | 216 | $versionedKeys = array_merge( 217 | $this->model->getVersionedAttributeNames(), 218 | [$this->model->getLatestVersionColumn(), $this->model->getVersionColumn(), $this->model->getVersionKeyName()] 219 | ); 220 | 221 | foreach ($values as $key => $value) { 222 | if (! $this->isVersionedKey($key, $versionedKeys)) { 223 | $array[$key] = $value; 224 | } 225 | } 226 | 227 | return $array; 228 | } 229 | 230 | /** 231 | * Get affected ids. 232 | * 233 | * @param array $values 234 | * @return array 235 | */ 236 | protected function getVersionValues(array $values) 237 | { 238 | $array = []; 239 | 240 | $versionedKeys = $this->model->getVersionedAttributeNames(); 241 | 242 | foreach ($values as $key => $value) { 243 | if ($newKey = $this->isVersionedKey($key, $versionedKeys)) { 244 | $array[$newKey] = $value; 245 | } 246 | } 247 | 248 | return $array; 249 | } 250 | 251 | /** 252 | * Check if key is in versioned keys. 253 | * 254 | * @param string $key 255 | * @param array $versionedKeys 256 | * @return string|null 257 | */ 258 | protected function isVersionedKey($key, array $versionedKeys) 259 | { 260 | $segments = explode(".",$key); 261 | 262 | if (count($segments) > 2) { 263 | throw new Exception("Key '".$key."' has too many fractions."); 264 | } 265 | 266 | if (count($segments) == 1 && in_array($segments[0], $versionedKeys)) { 267 | return $segments[0]; 268 | } 269 | 270 | if (count($segments) == 2 && $segments[0] == $this->model->getVersionTable() && in_array($segments[1], $versionedKeys)) { 271 | return $segments[1]; 272 | } 273 | 274 | return null; 275 | } 276 | 277 | } 278 | -------------------------------------------------------------------------------- /src/SoftDeletes.php: -------------------------------------------------------------------------------- 1 | getDeletedAtColumn(); 19 | 20 | if (isset($this->versioned) && in_array($deletedAt, $this->versioned)) { 21 | return $this->getVersionTable().'.'.$deletedAt; 22 | } 23 | 24 | return $this->getTable().'.'.$deletedAt; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Versionable.php: -------------------------------------------------------------------------------- 1 | getVersionKeyName()); 28 | 29 | return parent::newFromBuilder($attributes, $connection); 30 | } 31 | 32 | /** 33 | * Create a new Eloquent query builder for the model. 34 | * 35 | * @param \Illuminate\Database\Query\Builder $query 36 | * @return \ProAI\Versioning\Builder|static 37 | */ 38 | public function newEloquentBuilder($query) 39 | { 40 | return new Builder($query); 41 | } 42 | 43 | /** 44 | * Get the names of the attributes that are versioned. 45 | * 46 | * @return array 47 | */ 48 | public function getVersionedAttributeNames() 49 | { 50 | return (! empty($this->versioned)) ? $this->versioned : []; 51 | } 52 | 53 | /** 54 | * Get the version key name. 55 | * 56 | * @return string 57 | */ 58 | public function getVersionKeyName() 59 | { 60 | return 'ref_' . $this->getKeyName(); 61 | } 62 | 63 | /** 64 | * Get the version table associated with the model. 65 | * 66 | * @return string 67 | */ 68 | public function getVersionTable() 69 | { 70 | return $this->getTable() . '_version'; 71 | } 72 | 73 | /** 74 | * Get the table qualified version key name. 75 | * 76 | * @return string 77 | */ 78 | public function getQualifiedVersionKeyName() 79 | { 80 | return $this->getVersionTable().'.'.$this->getVersionKeyName(); 81 | } 82 | 83 | /** 84 | * Get the name of the "latest version" column. 85 | * 86 | * @return string 87 | */ 88 | public function getLatestVersionColumn() 89 | { 90 | return defined('static::LATEST_VERSION') ? static::LATEST_VERSION : 'latest_version'; 91 | } 92 | 93 | /** 94 | * Get the fully qualified "latest version" column. 95 | * 96 | * @return string 97 | */ 98 | public function getQualifiedLatestVersionColumn() 99 | { 100 | return $this->getTable().'.'.$this->getLatestVersionColumn(); 101 | } 102 | 103 | /** 104 | * Get the name of the "version" column. 105 | * 106 | * @return string 107 | */ 108 | public function getVersionColumn() 109 | { 110 | return defined('static::VERSION') ? static::VERSION : 'version'; 111 | } 112 | 113 | /** 114 | * Get the fully qualified "version" column. 115 | * 116 | * @return string 117 | */ 118 | public function getQualifiedVersionColumn() 119 | { 120 | return $this->getVersionTable().'.'.$this->getVersionColumn(); 121 | } 122 | 123 | } 124 | -------------------------------------------------------------------------------- /src/VersioningScope.php: -------------------------------------------------------------------------------- 1 | hasVersionJoin($builder, $model->getVersionTable())) { 30 | $builder->join($model->getVersionTable(), function($join) use ($model) { 31 | $join->on($model->getQualifiedKeyName(), '=', $model->getQualifiedVersionKeyName()); 32 | $join->on($model->getQualifiedVersionColumn(), '=', $model->getQualifiedLatestVersionColumn()); 33 | }); 34 | } 35 | 36 | $this->extend($builder); 37 | } 38 | 39 | /** 40 | * Remove the scope from the given Eloquent query builder. 41 | * 42 | * @param \Illuminate\Database\Eloquent\Builder $builder 43 | * @param \Illuminate\Database\Eloquent\Model $model 44 | * @return void 45 | */ 46 | public function remove(Builder $builder, Model $model) 47 | { 48 | $table = $model->getVersionTable(); 49 | 50 | $query = $builder->getQuery(); 51 | 52 | $query->joins = collect($query->joins)->reject(function($join) use ($table) 53 | { 54 | return $this->isVersionJoinConstraint($join, $table); 55 | })->values()->all(); 56 | } 57 | 58 | /** 59 | * Extend the query builder with the needed functions. 60 | * 61 | * @param \Illuminate\Database\Eloquent\Builder $builder 62 | * @return void 63 | */ 64 | public function extend(Builder $builder) 65 | { 66 | foreach ($this->extensions as $extension) 67 | { 68 | $this->{"add{$extension}"}($builder); 69 | } 70 | } 71 | 72 | /** 73 | * Add the version extension to the builder. 74 | * 75 | * @param \Illuminate\Database\Eloquent\Builder $builder 76 | * @return void 77 | */ 78 | protected function addVersion(Builder $builder) 79 | { 80 | $builder->macro('version', function(Builder $builder, $version) { 81 | $model = $builder->getModel(); 82 | 83 | $this->remove($builder, $builder->getModel()); 84 | 85 | $builder->join($model->getVersionTable(), function($join) use ($model, $version) { 86 | $join->on($model->getQualifiedKeyName(), '=', $model->getQualifiedVersionKeyName()); 87 | $join->where($model->getQualifiedVersionColumn(), '=', $version); 88 | }); 89 | 90 | return $builder; 91 | }); 92 | } 93 | 94 | /** 95 | * Add the allVersions extension to the builder. 96 | * 97 | * @param \Illuminate\Database\Eloquent\Builder $builder 98 | * @return void 99 | */ 100 | protected function addAllVersions(Builder $builder) 101 | { 102 | $builder->macro('allVersions', function(Builder $builder) { 103 | $model = $builder->getModel(); 104 | 105 | $this->remove($builder, $builder->getModel()); 106 | 107 | $builder->join($model->getVersionTable(), function($join) use ($model) { 108 | $join->on($model->getQualifiedKeyName(), '=', $model->getQualifiedVersionKeyName()); 109 | }); 110 | 111 | return $builder; 112 | }); 113 | } 114 | 115 | /** 116 | * Add the moment extension to the builder. 117 | * 118 | * @param \Illuminate\Database\Eloquent\Builder $builder 119 | * @return void 120 | */ 121 | protected function addMoment(Builder $builder) 122 | { 123 | $builder->macro('moment', function(Builder $builder, Carbon $moment) { 124 | $model = $builder->getModel(); 125 | 126 | $this->remove($builder, $builder->getModel()); 127 | 128 | $builder->join($model->getVersionTable(), function($join) use ($model, $moment) { 129 | $join->on($model->getQualifiedKeyName(), '=', $model->getQualifiedVersionKeyName()); 130 | $join->where('updated_at', '<=', $moment)->orderBy('updated_at', 'desc')->limit(1); 131 | })->orderBy('updated_at', 'desc')->limit(1); 132 | 133 | return $builder; 134 | }); 135 | } 136 | 137 | /** 138 | * Determine if the given join clause is a version constraint. 139 | * 140 | * @param \Illuminate\Database\Query\JoinClause $join 141 | * @param string $column 142 | * @return bool 143 | */ 144 | protected function isVersionJoinConstraint(JoinClause $join, $table) 145 | { 146 | return $join->type == 'inner' && $join->table == $table; 147 | } 148 | 149 | /** 150 | * Determine if the given builder contains a join with the given table 151 | * 152 | * @param Builder $builder 153 | * @param string $table 154 | * @return bool 155 | */ 156 | protected function hasVersionJoin(Builder $builder, string $table) 157 | { 158 | return collect($builder->getQuery()->joins)->pluck('table')->contains($table); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /tests/Models/Post.php: -------------------------------------------------------------------------------- 1 | set('database.default', 'testing'); 17 | $app['config']->set('database.connections.testing', [ 18 | 'driver' => 'sqlite', 19 | 'database' => ':memory:', 20 | 'prefix' => '', 21 | ]); 22 | } 23 | 24 | /** 25 | * {@inheritdoc} 26 | */ 27 | public function setUp() 28 | { 29 | parent::setUp(); 30 | $this->loadMigrationsFrom(__DIR__.'/database/migrations'); 31 | $this->withFactories(__DIR__.'/database/factories'); 32 | } 33 | } -------------------------------------------------------------------------------- /tests/Unit/BuilderTest.php: -------------------------------------------------------------------------------- 1 | create([]); 19 | 20 | $this->assertArraySubset([ 21 | 'username' => $model->username, 22 | 'email' => $model->email, 23 | 'city' => $model->city, 24 | 'latest_version' => $model->latest_version, 25 | 'updated_at' => $model->updated_at, 26 | 'created_at' => $model->created_at 27 | ], User::first()->toArray()); 28 | } 29 | 30 | /** 31 | * @test 32 | */ 33 | public function itWillRetrieveTheLatestVersionedAttributes() 34 | { 35 | /** @var User $model */ 36 | $model = factory(User::class)->create([]); 37 | 38 | $model->update([ 39 | 'city' => 'Citadel' 40 | ]); 41 | 42 | $this->assertArraySubset([ 43 | 'latest_version' => 2, 44 | ], User::first()->toArray()); 45 | } 46 | 47 | /** 48 | * @test 49 | */ 50 | public function itWillRetrieveTheCorrectVersionsAttributes() 51 | { 52 | /** @var User $model */ 53 | $model = factory(User::class)->create([]); 54 | $city = $model->city; 55 | 56 | $model->update([ 57 | 'city' => 'Citadel' 58 | ]); 59 | 60 | $model->update([ 61 | 'city' => 'Ricklantis' 62 | ]); 63 | 64 | $this->assertArraySubset([ 65 | 'city' => $city, 66 | 'version' => 1 67 | ], User::version(1)->find($model->id)->toArray()); 68 | 69 | $this->assertArraySubset([ 70 | 'city' => 'Citadel', 71 | 'version' => 2 72 | ], User::version(2)->find($model->id)->toArray()); 73 | 74 | $this->assertArraySubset([ 75 | 'city' => 'Ricklantis', 76 | 'version' => 3 77 | ], User::version(3)->find($model->id)->toArray()); 78 | } 79 | 80 | /** 81 | * @test 82 | */ 83 | public function itWillRetrieveAllVersions() 84 | { 85 | /** @var User $model */ 86 | $model = factory(User::class)->create([]); 87 | $city = $model->city; 88 | 89 | $model->update([ 90 | 'city' => 'Citadel' 91 | ]); 92 | 93 | $model->update([ 94 | 'city' => 'Ricklantis' 95 | ]); 96 | 97 | $this->assertArraySubset([ 98 | [ 99 | 'city' => $city 100 | ], 101 | [ 102 | 'city' => 'Citadel' 103 | ], 104 | [ 105 | 'city' => 'Ricklantis' 106 | ] 107 | ], User::allVersions()->get()->toArray()); 108 | } 109 | 110 | /** 111 | * @test 112 | */ 113 | public function itWillRetrieveTheCorrectMomentsAttributes() 114 | { 115 | /** @var User $model */ 116 | $model = factory(User::class)->create([ 117 | 'updated_at' => Carbon::now()->subDays(2) 118 | ]); 119 | $date = $model->created_at; 120 | 121 | DB::table('users_version')->insert([ 122 | 'ref_id' => 1, 123 | 'version' => 2, 124 | 'email' => $model->email, 125 | 'city' => 'Citadel', 126 | 'updated_at' => $date->copy()->addDays(1) 127 | ]); 128 | 129 | DB::table('users_version')->insert([ 130 | 'ref_id' => 1, 131 | 'version' => 3, 132 | 'email' => $model->email, 133 | 'city' => 'Ricklantis', 134 | 'updated_at' => $date->copy()->addDays(2) 135 | ]); 136 | 137 | $this->assertArraySubset([ 138 | 'version' => 1 139 | ], User::moment($date)->find($model->id)->toArray()); 140 | 141 | $this->assertArraySubset([ 142 | 'version' => 2 143 | ], User::moment($date->copy()->addDays(1))->find($model->id)->toArray()); 144 | 145 | $this->assertArraySubset([ 146 | 'version' => 3 147 | ], User::moment($date->copy()->addDays(2))->find($model->id)->toArray()); 148 | } 149 | 150 | /** 151 | * @test 152 | */ 153 | public function itWillRemovePreviousJoins() 154 | { 155 | /** @var User $model */ 156 | $model = factory(User::class)->create([]); 157 | $city = $model->city; 158 | 159 | $model->update([ 160 | 'city' => 'Citadel' 161 | ]); 162 | 163 | $builder = User::version(1); 164 | 165 | // It should have one join right now 166 | $this->assertEquals(1, collect($builder->getQuery()->joins)->where('table', '=', 'users_version')->count()); 167 | 168 | $builder->version(2); 169 | 170 | // It should still have one join right now 171 | $this->assertEquals(1, collect($builder->getQuery()->joins)->where('table', '=', 'users_version')->count()); 172 | } 173 | 174 | /** 175 | * @test 176 | * 177 | * @dataProvider modelProvider 178 | * @param string $model 179 | */ 180 | public function itWillDeleteTheVersionedTable(string $model) 181 | { 182 | factory($model)->create([]); 183 | factory($model)->create([]); 184 | 185 | $model::version(1)->delete(); 186 | 187 | $this->assertEquals(0, User::all()->count()); 188 | } 189 | 190 | /** 191 | * @test 192 | * 193 | * @dataProvider modelProvider 194 | * @param string $model 195 | */ 196 | public function itWillForceDeleteTheVersionedTable(string $model) 197 | { 198 | factory($model)->create([]); 199 | factory($model)->create([]); 200 | 201 | $model::version(1)->forceDelete(); 202 | 203 | $this->assertEquals(0, User::all()->count()); 204 | } 205 | 206 | public function modelProvider() 207 | { 208 | return [ 209 | [ 210 | User::class 211 | ], 212 | [ 213 | Post::class 214 | ] 215 | ]; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /tests/Unit/SoftDeletesTest.php: -------------------------------------------------------------------------------- 1 | create([]); 22 | $model->delete(); 23 | 24 | $this->assertArraySubset([ 25 | 'id' => $model->id, 26 | 'deleted_at' => $model->deleted_at 27 | ], $model::withTrashed()->first()->toArray()); 28 | } 29 | 30 | /** 31 | * @test 32 | */ 33 | public function itWillGetTheCorrectDeletedAtColumnOnTheMainTable() 34 | { 35 | /** @var Post $model */ 36 | $model = factory(Post::class)->create([]); 37 | 38 | $this->assertEquals('posts.deleted_at', $model->getQualifiedDeletedAtColumn()); 39 | } 40 | 41 | /** 42 | * @test 43 | */ 44 | public function itWillGetTheCorrectDeletedAtColumnOnTheVersionTable() 45 | { 46 | /** @var User $model */ 47 | $model = factory(User::class)->create([]); 48 | 49 | $this->assertEquals('users_version.deleted_at', $model->getQualifiedDeletedAtColumn()); 50 | } 51 | 52 | /** 53 | * @test 54 | * @throws \Exception 55 | */ 56 | public function itWillSaveDeletedAtInTheMainTable() 57 | { 58 | /** @var Post $model */ 59 | $model = factory(Post::class)->create([]); 60 | $model->delete(); 61 | 62 | $this->assertDatabaseHas('posts', [ 63 | 'id' => $model->id, 64 | 'deleted_at' => $model->deleted_at 65 | ]); 66 | } 67 | 68 | /** 69 | * @test 70 | * @throws \Exception 71 | */ 72 | public function itWillSaveDeletedAtInTheVersionTable() 73 | { 74 | /** @var User $model */ 75 | $model = factory(User::class)->create([]); 76 | $model->delete(); 77 | 78 | $this->assertDatabaseHas('users_version', [ 79 | 'ref_id' => $model->id, 80 | 'version' => 1, 81 | 'deleted_at' => null 82 | ]); 83 | 84 | $this->assertDatabaseHas('users_version', [ 85 | 'ref_id' => $model->id, 86 | 'version' => 2, 87 | 'deleted_at' => $model->deleted_at->format('Y-m-d H:i:s') 88 | ]); 89 | } 90 | 91 | public function modelProvider() 92 | { 93 | return [ 94 | [ 95 | User::class 96 | ], 97 | [ 98 | Post::class 99 | ] 100 | ]; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /tests/Unit/VersionableTest.php: -------------------------------------------------------------------------------- 1 | create([]); 18 | 19 | $this->assertDatabaseHas($model->getTable(), [ 20 | 'id' => $model->id, 21 | 'username' => $model->username, 22 | ]); 23 | 24 | $this->assertDatabaseHas($model->getVersionTable(), [ 25 | 'ref_id' => $model->id, 26 | 'version' => $model->latest_version, 27 | 'email' => $model->email, 28 | 'city' => $model->city 29 | ]); 30 | } 31 | 32 | /** 33 | * @test 34 | */ 35 | public function itWillVersionModelsWhenUpdating() 36 | { 37 | /** @var User $model */ 38 | $model = factory(User::class)->create([]); 39 | $email = $model->email; 40 | 41 | $model->update([ 42 | 'email' => 'rick@wubba-lubba-dub.dub' 43 | ]); 44 | 45 | $this->assertDatabaseHas($model->getTable(), [ 46 | 'username' => $model->username, 47 | ]); 48 | 49 | $this->assertDatabaseHas($model->getVersionTable(), [ 50 | 'ref_id' => $model->id, 51 | 'version' => 1, 52 | 'email' => $email, 53 | 'city' => $model->city 54 | ]); 55 | 56 | $this->assertDatabaseHas($model->getVersionTable(), [ 57 | 'ref_id' => $model->id, 58 | 'version' => 2, 59 | 'email' => $model->email, 60 | 'city' => $model->city 61 | ]); 62 | } 63 | 64 | /** 65 | * @test 66 | */ 67 | public function itWillVersionModelsWhenSaving() 68 | { 69 | /** @var User $model */ 70 | $model = factory(User::class)->create([]); 71 | $email = $model->email; 72 | 73 | $model->email = 'rick@wubba-lubba-dub.dub'; 74 | $model->save(); 75 | 76 | $this->assertDatabaseHas($model->getTable(), [ 77 | 'username' => $model->username, 78 | ]); 79 | 80 | $this->assertDatabaseHas($model->getVersionTable(), [ 81 | 'ref_id' => $model->id, 82 | 'version' => 1, 83 | 'email' => $email, 84 | 'city' => $model->city 85 | ]); 86 | 87 | $this->assertDatabaseHas($model->getVersionTable(), [ 88 | 'ref_id' => $model->id, 89 | 'version' => 2, 90 | 'email' => $model->email, 91 | 'city' => $model->city 92 | ]); 93 | } 94 | 95 | /** 96 | * @test 97 | */ 98 | public function itWillVersionModelsWhenInserting() 99 | { 100 | /** @var User $model */ 101 | $model = factory(User::class)->make([]); 102 | $model->created_at = Carbon::now(); 103 | $model->updated_at = Carbon::now(); 104 | 105 | User::insert($model->toArray()); 106 | 107 | $this->assertDatabaseHas($model->getTable(), [ 108 | 'id' => 1, 109 | 'username' => $model->username, 110 | ]); 111 | 112 | $this->assertDatabaseHas($model->getVersionTable(), [ 113 | 'ref_id' => 1, 114 | 'version' => 1, 115 | 'email' => $model->email, 116 | 'city' => $model->city 117 | ]); 118 | } 119 | 120 | /** 121 | * @test 122 | */ 123 | public function itWillUpdateTheLatestVersionWhenCreating() 124 | { 125 | /** @var User $model */ 126 | $model = factory(User::class)->create([]); 127 | 128 | $this->assertEquals(1, $model->latest_version); 129 | } 130 | 131 | /** 132 | * @test 133 | */ 134 | public function itWillUpdateTheLatestVersionWhenUpdating() 135 | { 136 | /** @var User $model */ 137 | $model = factory(User::class)->create([]); 138 | 139 | $model->update([ 140 | 'email' => 'rick@wubba-lubba-dub.dub' 141 | ]); 142 | 143 | $this->assertEquals(2, $model->latest_version); 144 | } 145 | 146 | /** 147 | * @test 148 | */ 149 | public function itWillUpdateTheLatestVersionWhenSaving() 150 | { 151 | /** @var User $model */ 152 | $model = factory(User::class)->create([]); 153 | 154 | $model->email = 'rick@wubba-lubba-dub.dub'; 155 | $model->save(); 156 | 157 | $this->assertEquals(2, $model->latest_version); 158 | } 159 | 160 | /** 161 | * @test 162 | */ 163 | public function itWillOnlyVersionVersionedAttributes() 164 | { 165 | /** @var User $model */ 166 | $model = factory(User::class)->create([]); 167 | $email = $model->email; 168 | 169 | $model->email = 'rick@wubba-lubba-dub.dub'; 170 | $model->username = 'RickSanchez'; 171 | $model->save(); 172 | 173 | $this->assertDatabaseHas($model->getTable(), [ 174 | 'username' => 'RickSanchez', 175 | ]); 176 | 177 | $this->assertDatabaseHas($model->getVersionTable(), [ 178 | 'ref_id' => $model->id, 179 | 'version' => 1, 180 | 'email' => $email, 181 | 'city' => $model->city 182 | ]); 183 | 184 | $this->assertDatabaseHas($model->getVersionTable(), [ 185 | 'ref_id' => $model->id, 186 | 'version' => 2, 187 | 'email' => $model->email, 188 | 'city' => $model->city 189 | ]); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /tests/database/factories/PostFactory.php: -------------------------------------------------------------------------------- 1 | define(Post::class, function (Faker $faker) { 13 | return [ 14 | 'title' => $faker->title, 15 | 'content' => $faker->text, 16 | ]; 17 | }); -------------------------------------------------------------------------------- /tests/database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | define(User::class, function (Faker $faker) { 13 | return [ 14 | 'email' => $faker->unique()->safeEmail, 15 | 'username' => $faker->userName, 16 | 'city' => $faker->city 17 | ]; 18 | }); -------------------------------------------------------------------------------- /tests/database/migrations/2014_10_12_000000_create_users_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->integer('latest_version'); 19 | $table->string('username'); 20 | $table->timestamp('created_at'); 21 | }); 22 | 23 | Schema::create('users_version', function(Blueprint $table) { 24 | $table->integer('ref_id')->unsigned(); 25 | $table->integer('version')->unsigned(); 26 | $table->string('email'); 27 | $table->string('city'); 28 | $table->timestamp('updated_at'); 29 | $table->softDeletes(); 30 | 31 | $table->primary(['ref_id', 'version']); 32 | }); 33 | } 34 | 35 | /** 36 | * Reverse the migrations. 37 | * 38 | * @return void 39 | */ 40 | public function down() 41 | { 42 | Schema::dropIfExists('users'); 43 | Schema::dropIfExists('users_version'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/database/migrations/2014_10_12_100000_create_posts_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->integer('latest_version'); 19 | $table->string('title'); 20 | $table->timestamp('created_at'); 21 | $table->softDeletes(); 22 | }); 23 | 24 | Schema::create('posts_version', function(Blueprint $table) { 25 | $table->integer('ref_id')->unsigned(); 26 | $table->integer('version')->unsigned(); 27 | $table->text('content'); 28 | $table->timestamp('updated_at'); 29 | 30 | $table->primary(['ref_id', 'version']); 31 | }); 32 | } 33 | 34 | /** 35 | * Reverse the migrations. 36 | * 37 | * @return void 38 | */ 39 | public function down() 40 | { 41 | Schema::dropIfExists('posts'); 42 | Schema::dropIfExists('posts_version'); 43 | } 44 | } 45 | --------------------------------------------------------------------------------