├── .travis.yml ├── LICENSE ├── README.md ├── composer.json └── src ├── Builder.php ├── Exceptions └── IncompatibleModelMismatchException.php ├── Scopes └── VersioningScope.php ├── Traits └── Versioned.php └── VersionDiffer.php /.travis.yml: -------------------------------------------------------------------------------- 1 | # Travis CI configuration 2 | 3 | language: php 4 | 5 | php: 6 | - 5.5 7 | - 5.6 8 | - hhvm 9 | - 7.0 10 | 11 | matrix: 12 | allow_failures: 13 | - php: 7.0 14 | - php: hhvm 15 | 16 | install: 17 | - composer install 18 | 19 | script: 20 | - phpunit 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Seb Barre 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Eloquent Versioned 2 | 3 | Adds transparent versioning support to Laravel 5.2's Eloquent ORM. 4 | 5 | **WARNING: This repository is currently super-duper experimental. I will gladly accept pull requests and issues, but you probably shouldn't use this in production, and the interfaces may change without notice (although major changes will bump the version).** 6 | 7 | **It was also recently updated to bring global scopes in line with Laravel 5.2 8 | so if you are not yet on 5.2, stick with release 0.0.7.** 9 | 10 | When using this trait (and with a table that includes the required fields), saving your model will actually create a new row instead, and increment the version number. 11 | 12 | Using global scopes, old versions are ignored in the standard ORM operations (selects, updates, deletes) and relations (hasOne, hasMany, belongsTo, etc). 13 | 14 | The package also provides some special methods to include old versions in queries (or only query old versions) which can be useful for showing a model's history, or the like. 15 | 16 | ## Installation 17 | 18 | To add via Composer: 19 | 20 | ``` 21 | composer require sbarre/eloquent-versioned --no-dev 22 | ``` 23 | 24 | Use the `--no-dev` flag to avoid pulling down all the testing dependencies (like the *entire Laravel framework*). 25 | 26 | ## Migrations 27 | 28 | Versioned models require that your database table contain 3 fields to handle the versioning. 29 | 30 | If you are creating a new table, or if you are changing an existing table, include the following lines in the `up()` method of the migration: 31 | 32 | ```php 33 | $table->integer('model_id')->unsigned()->default(1); 34 | $table->integer('version')->unsigned()->default(1); 35 | $table->integer('is_current_version')->unsigned()->default(1); 36 | $table->index('is_current_version'); 37 | $table->index('model_id'); 38 | $table->index('version'); 39 | ``` 40 | 41 | If your migration was altering an existing table, you should include these lines in the `down()` method of your migration: 42 | 43 | ```php 44 | $table->dropColumn(['model_id','version','is_current_version']); 45 | $table->dropIndex(['model_id','version','is_current_version']); 46 | ``` 47 | 48 | #### Caveats 49 | 50 | If you change the constants in `EloquentVersioned\VersionedBuilder` to rename the columns, remember to change them in your migrations as well. 51 | 52 | ## Usage 53 | 54 | In your Eloquent model class, start by adding the `use` statement for the Trait: 55 | 56 | ```php 57 | use EloquentVersioned\Traits\Versioned; 58 | ``` 59 | 60 | When the trait boots it will apply the proper scope, and provides overrides on various Eloquent methods to support versioned records. 61 | 62 | Once the trait is applied, you use your models as usual, with the standard queries behaving as usual. 63 | 64 | ```php 65 | $project = Project::create([ 66 | 'name' => 'Project Name', 67 | 'description' => 'Project description goes here' 68 | ])->fresh(); 69 | 70 | print_r($project->toArray()); 71 | ``` 72 | 73 | This would then output (for example): 74 | 75 | ```php 76 | Array 77 | ( 78 | [id] => 1 79 | [version] => 1 80 | [name] => Project Name 81 | [description] => Project description goes here 82 | [created_at] => 2015-05-24 17:16:05 83 | [updated_at] => 2015-05-24 17:16:05 84 | ) 85 | ``` 86 | 87 | The actual database row looks like this: 88 | 89 | ```php 90 | Array 91 | ( 92 | [id] => 1 93 | [model_id] => 1 94 | [version] => 1 95 | [is_current_version] => 1 96 | [name] => Updated project Name 97 | [description] => Project description goes here 98 | [created_at] => 2015-05-24 17:16:05 99 | [updated_at] => 2015-05-24 17:16:05 100 | ) 101 | ``` 102 | 103 | Then if you change the model and save: 104 | 105 | ```php 106 | $project->name = 'Updated project name'; 107 | $project->save(); 108 | 109 | print_r($project->toArray()); 110 | ``` 111 | This would then output: 112 | 113 | ```php 114 | Array 115 | ( 116 | [id] => 1 117 | [version] => 2 118 | [name] => Updated project Name 119 | [description] => Project description goes here 120 | [created_at] => 2015-05-24 17:16:05 121 | [updated_at] => 2015-05-24 17:16:45 122 | ) 123 | ``` 124 | 125 | The model mutates the `model_id` column into `id`, and hides some of the version-specific columns. In reality this is actually the same database row that now looks like this: 126 | 127 | ```php 128 | Array 129 | ( 130 | [id] => 1 131 | [model_id] => 1 132 | [version] => 2 133 | [is_current_version] => 1 134 | [name] => Updated project Name 135 | [description] => Project description goes here 136 | [created_at] => 2015-05-24 17:16:05 137 | [updated_at] => 2015-05-24 17:16:45 138 | ) 139 | ``` 140 | 141 | While a new row is inserted to save our previous version, which now looks like this: 142 | 143 | ```php 144 | Array 145 | ( 146 | [id] => 2 147 | [model_id] => 1 148 | [version] => 1 149 | [is_current_version] => 0 150 | [name] => Project Name 151 | [description] => Project description goes here 152 | [created_at] => 2015-05-24 17:16:05 153 | [updated_at] => 2015-05-24 17:16:05 154 | ) 155 | ``` 156 | 157 | So the `is_current_version` property is what the global scope is applied against, limiting all select queries to only records where `is_current_version = 1`. 158 | 159 | Calling `save()` on a model replicates the original version into a new row (with `is_current_version = 0`), then increments the `version_id` property on our current model, changes the appropriate timestamps, and saves it. 160 | 161 | If you are making a very minor change to a model and you don't want to create a new version, you can call `saveMinor()` instead. 162 | 163 | ```php 164 | $project->saveMinor(); // doesn't create a new version 165 | ``` 166 | 167 | #### Methods for dealing with old versions 168 | 169 | If you want to retrieve a list of all versions of a model (or include old versions in a bigger query): 170 | 171 | ```php 172 | $projectVersions = Project::withOldVersions()->find(1); 173 | ``` 174 | 175 | If run after our example above, this would return an array with 2 models. 176 | 177 | You can also retrieve a list of *only* old models by using: 178 | 179 | ```php 180 | $oldVersions = Project::onlyOldVersions()->find(1); 181 | ``` 182 | 183 | Otherwise, the rest of Eloquent's ORM operations should work as usual, including the out-of-the-box relations. 184 | 185 | #### Methods for moving through a model's versions 186 | 187 | If you want to navigate through all of model's versions, in a linked-list manner: 188 | 189 | ```php 190 | $current = Project::find(1); 191 | 192 | $previous = $current->getPreviousModel(); 193 | $next = $previous->getNextModel(); 194 | 195 | // $next == $current 196 | ``` 197 | 198 | If you are at the most recent version, `getNextModel()` will return `null` and likewise if you are at the oldest version, `getPreviousModel()` will return `null`. 199 | 200 | ## Support & Roadmap 201 | 202 | As indicated at the top, this package is still **very experimental** and is under active development. The current roadmap includes test coverage and more extensive real-world testing, so pull requests and issues are always welcome! 203 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sbarre/eloquent-versioned", 3 | "description": "Add transparent versioning to Laravel 5.2's Eloquent ORM", 4 | "keywords": [ 5 | "laravel", 6 | "versioning", 7 | "orm", 8 | "eloquent" 9 | ], 10 | "homepage": "https://github.com/sbarre/eloquent-versioned", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Sebastien Barre", 15 | "email": "sbarre@gmail.com" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=5.4", 20 | "illuminate/database": "~5.2" 21 | }, 22 | "require-dev": { 23 | "phpunit/phpunit": "4.6.*", 24 | "laravel/framework": "~5.2" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "EloquentVersioned\\": "src/" 29 | } 30 | }, 31 | "autoload-dev": { 32 | "psr-4": { 33 | "EloquentVersioned\\Tests\\": "tests/" 34 | } 35 | }, 36 | "extra": { 37 | "branch-alias": { 38 | "dev-master": "0.x-dev" 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Builder.php: -------------------------------------------------------------------------------- 1 | query->orders) == 0) { 20 | $this->orderBy(static::COLUMN_MODEL_ID); 21 | } 22 | 23 | return parent::first($columns); 24 | } 25 | 26 | /** 27 | * @param array $columns 28 | * 29 | * @return \Illuminate\Database\Eloquent\Model[] 30 | */ 31 | public function getModels($columns = array('*')) 32 | { 33 | if (count($this->query->orders) == 0) { 34 | $this->orderBy(static::COLUMN_MODEL_ID); 35 | } 36 | 37 | return parent::getModels($columns); 38 | } 39 | 40 | /** 41 | * A method to use with versioned scopes to retrieve a collection of 42 | * non-current-version models (either with or without the current version) 43 | * 44 | * @param $id 45 | * @param array $columns 46 | * 47 | * @return \Illuminate\Database\Eloquent\Collection 48 | */ 49 | public function findAll($id, $columns = array('*')) 50 | { 51 | return $this->findMany([$id], $columns); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Exceptions/IncompatibleModelMismatchException.php: -------------------------------------------------------------------------------- 1 | where($model->getQualifiedIsCurrentVersionColumn(), 1); 15 | } 16 | 17 | /** 18 | * @param Builder $builder 19 | */ 20 | public function extend(Builder $builder) 21 | { 22 | foreach ($this->extensions as $extension) { 23 | $this->{"add{$extension}"}($builder); 24 | } 25 | } 26 | 27 | /** 28 | * @param Builder $builder 29 | */ 30 | protected function addWithOldVersions(Builder $builder) 31 | { 32 | $builder->macro('withOldVersions', function (Builder $builder) { 33 | return $builder->withoutGlobalScope($this); 34 | }); 35 | } 36 | 37 | /** 38 | * @param Builder $builder 39 | */ 40 | protected function addOnlyOldVersions(Builder $builder) 41 | { 42 | $builder->macro('onlyOldVersions', function (Builder $builder) { 43 | $model = $builder->getModel(); 44 | 45 | $builder->withoutGlobalScope($this)->where($model->getQualifiedIsCurrentVersionColumn(),0); 46 | 47 | return $builder; 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Traits/Versioned.php: -------------------------------------------------------------------------------- 1 | hideVersioned); 28 | } 29 | 30 | /* 31 | * ACCESSORS + MUTATORS 32 | */ 33 | 34 | /** 35 | * @return mixed 36 | */ 37 | public function getIdAttribute() 38 | { 39 | return ($this->{static::getIsCurrentVersionColumn()} == 0) ? 40 | $this->attributes[$this->primaryKey] : 41 | $this->attributes[static::getModelIdColumn()]; 42 | } 43 | 44 | /* 45 | * ELOQUENT OVERRIDES 46 | */ 47 | 48 | /** 49 | * @return string 50 | */ 51 | public function getKeyName() 52 | { 53 | return $this->isVersioned ? $this->getModelIdColumn() : $this->primaryKey; 54 | } 55 | 56 | /** 57 | * @param $query 58 | * 59 | * @return VersionedBuilder 60 | */ 61 | public function newEloquentBuilder($query) 62 | { 63 | return new VersionedBuilder($query); 64 | } 65 | 66 | /** 67 | * @return array 68 | */ 69 | public function attributesToArray($hide = array()) 70 | { 71 | $parentAttributes = parent::attributesToArray(); 72 | 73 | if ((!$this->isVersioned) || ($this->{static::getIsCurrentVersionColumn()} == 0)) { 74 | return $parentAttributes; 75 | } 76 | 77 | $attributes = []; 78 | foreach ($parentAttributes as $key => $value) { 79 | if (!in_array($key, $this->getHideVersioned($hide))) { 80 | $attributes[$key] = $value; 81 | } 82 | } 83 | 84 | return $attributes; 85 | } 86 | 87 | /** 88 | * @param Builder $query 89 | * 90 | * @return Builder 91 | */ 92 | protected function setKeysForSaveQuery(Builder $query) 93 | { 94 | $query->where($this->getKeyName(), '=', $this->getKeyForSaveQuery()) 95 | ->where($this->getQualifiedIsCurrentVersionColumn(), 1); 96 | 97 | return $query; 98 | } 99 | 100 | /** 101 | * @param array $options 102 | * 103 | * @return mixed 104 | */ 105 | public function saveMinor(array $options = []) 106 | { 107 | return parent::save($options); 108 | } 109 | 110 | /** 111 | * Save a new version of the model 112 | * 113 | * @param array $options 114 | * 115 | * @return bool 116 | */ 117 | public function save(array $options = []) 118 | { 119 | $query = $this->newQueryWithoutScopes(); 120 | 121 | $db = $this->getConnection(); 122 | 123 | // If the "saving" event returns false we'll bail out of the save and 124 | // return false, indicating that the save failed. This provides a chance 125 | // for any listeners to cancel save operations if validations fail or 126 | // whatever. 127 | if ($this->fireModelEvent('saving') === false) { 128 | return false; 129 | } 130 | 131 | // If the model already exists in the database we can just update our 132 | // record that is already in this database using the current IDs in this 133 | // "where" clause to only update this model. Otherwise, we'll just 134 | // insert them. 135 | if ($this->exists) { 136 | 137 | $dirty = $this->isDirty(); 138 | 139 | if (count($dirty)>0) { 140 | 141 | $saved = $db->transaction(function () use ($query, $db, $options) { 142 | 143 | // create old version, save new one in place 144 | $oldVersion = $this->replicate([$this->primaryKey]); 145 | $oldVersion->forceFill($this->original); 146 | unset($oldVersion->attributes[$this->primaryKey]); 147 | $oldVersion->{static::getIsCurrentVersionColumn()} = 0; 148 | 149 | $oldVersion->performInsert($query, ['timestamps' => false]); 150 | 151 | // trigger the update event 152 | if ($this->fireModelEvent('updating') === false) { 153 | return false; 154 | } 155 | 156 | $this->updated_at = $this->freshTimestamp(); 157 | $this->{static::getVersionColumn()} = static::getNextVersion($this->{static::getModelIdColumn()}); 158 | $this->{static::getIsCurrentVersionColumn()} = 1; 159 | $this->updated_at = $this->freshTimestamp(); 160 | 161 | $saved = $this->performUpdate($query, $options); 162 | 163 | // clear any other current version columns - we have to do this 164 | // because we might be reverting from a previous non-current model, 165 | // and not just saving the currently-current(?) model. 166 | if ($saved) { 167 | 168 | $db->table( (new static)->getTable() ) 169 | ->where( static::getModelIdColumn(), $this->{static::getModelIdColumn()} ) 170 | ->where( static::getIsCurrentVersionColumn(), 1 ) 171 | ->where( $this->primaryKey, '<>', $this->attributes[ $this->primaryKey ] ) 172 | ->update( [ static::getIsCurrentVersionColumn() => 0] ); 173 | } 174 | 175 | // this returns from the closure, not the function! 176 | return $saved; 177 | 178 | }); 179 | } else { 180 | $saved = true; 181 | } 182 | } 183 | 184 | // If the model is brand new, we'll insert it into our database, 185 | // then set the model_id to the id of the newly created record. 186 | else { 187 | $this->{static::getIsCurrentVersionColumn()} = 1; 188 | $saved = $this->performInsert($query, $options); 189 | $this->{static::getModelIdColumn()} = $this->attributes[$this->primaryKey]; 190 | $saved = $saved && $this->performUpdate($query, $options); 191 | } 192 | 193 | if ($saved) { 194 | $this->finishSave($options); 195 | } 196 | 197 | return $saved; 198 | } 199 | 200 | protected function insertAndSetId(Builder $query, $attributes) 201 | { 202 | $id = $query->insertGetId($attributes, $keyName = $this->primaryKey); 203 | 204 | $this->setAttribute($keyName, $id); 205 | } 206 | 207 | /* 208 | * EXTENSIONS 209 | */ 210 | 211 | /** 212 | * @param Builder $query 213 | * @param Model $model 214 | */ 215 | protected function performVersionedInsert(Builder $query, array $options = array()) 216 | { 217 | // First we'll need to create a fresh query instance and touch the creation and 218 | // update timestamps on this model, which are maintained by us for developer 219 | // convenience. After, we will just continue saving these model instances. 220 | if ($this->timestamps && array_get($options, 'timestamps', true)) { 221 | $this->updateTimestamps(); 222 | } 223 | 224 | // If the model has an incrementing key, we can use the "insertGetId" method on 225 | // the query builder, which will give us back the final inserted ID for this 226 | // table from the database. Not all tables have to be incrementing though. 227 | $attributes = $this->attributes; 228 | 229 | if ($this->incrementing) { 230 | $this->insertAndSetId($query, $attributes); 231 | } 232 | 233 | // If the table is not incrementing we'll simply insert this attributes as they 234 | // are, as this attributes arrays must contain an "id" column already placed 235 | // there by the developer as the manually determined key for these models. 236 | else { 237 | $query->insert($attributes); 238 | } 239 | 240 | // We will go ahead and set the exists property to true, so that it is set when 241 | // the created event is fired, just in case the developer tries to update it 242 | // during the event. This will allow them to do so and run an update here. 243 | $this->exists = true; 244 | 245 | return true; 246 | 247 | } 248 | 249 | /** 250 | * @param bool $isVersioned 251 | * 252 | * @return $this 253 | */ 254 | public function setIsVersioned($isVersioned = true) 255 | { 256 | $this->isVersioned = $isVersioned; 257 | 258 | return $this; 259 | } 260 | 261 | /** 262 | * @param int $modelId 263 | * 264 | * @return int 265 | */ 266 | public static function getNextVersion($modelId) 267 | { 268 | return (new static)->getConnection()->table((new static)->getTable()) 269 | ->where(static::getModelIdColumn(), $modelId) 270 | ->max(static::getVersionColumn()) + 1; 271 | } 272 | 273 | /** 274 | * @return string 275 | */ 276 | public static function getModelIdColumn() 277 | { 278 | return VersionedBuilder::COLUMN_MODEL_ID; 279 | } 280 | 281 | /** 282 | * @return string 283 | */ 284 | public static function getQualifiedModelIdColumn() 285 | { 286 | return (new static)->getTable() . '.' . static::getModelIdColumn(); 287 | } 288 | 289 | /** 290 | * @return string 291 | */ 292 | public static function getVersionColumn() 293 | { 294 | return VersionedBuilder::COLUMN_VERSION; 295 | } 296 | 297 | /** 298 | * @return string 299 | */ 300 | public static function getQualifiedVersionColumn() 301 | { 302 | return (new static)->getTable() . '.' . static::getVersionColumn(); 303 | } 304 | 305 | /** 306 | * @return string 307 | */ 308 | public static function getIsCurrentVersionColumn() 309 | { 310 | return VersionedBuilder::COLUMN_IS_CURRENT_VERSION; 311 | } 312 | 313 | /** 314 | * @return string 315 | */ 316 | public static function getQualifiedIsCurrentVersionColumn() 317 | { 318 | return (new static)->getTable() . '.' . static::getIsCurrentVersionColumn(); 319 | } 320 | 321 | /** 322 | * @return mixed 323 | */ 324 | public static function withOldVersions() 325 | { 326 | return (new static)->newQueryWithoutScope(new VersioningScope); 327 | } 328 | 329 | /** 330 | * @return mixed 331 | */ 332 | public static function onlyOldVersions() 333 | { 334 | return (new static)->newQueryWithoutScope(new VersioningScope) 335 | ->where(static::getQualifiedIsCurrentVersionColumn(), 0); 336 | } 337 | 338 | /** 339 | * @return mixed 340 | */ 341 | public function getPreviousModel() 342 | { 343 | if ($this->version === 1) { 344 | return null; 345 | } 346 | 347 | return $this->withOldVersions() 348 | ->where('model_id', $this->model_id) 349 | ->where('version', ($this->version - 1)) 350 | ->first(); 351 | } 352 | 353 | /** 354 | * @return mixed 355 | */ 356 | public function getNextModel() 357 | { 358 | if ($this->is_current_version === true) { 359 | return null; 360 | } 361 | 362 | return $this->withOldVersions() 363 | ->where('model_id', $this->model_id) 364 | ->where('version', ($this->version + 1)) 365 | ->first(); 366 | } 367 | 368 | /** 369 | * Switch the model to a different version 370 | * 371 | * @param int $version 372 | */ 373 | public function revertTo($version) 374 | { 375 | $model = $this->onlyOldVersions() 376 | ->where('version',intval($version)) 377 | ->where('model_id', $this->model_id) 378 | ->first(); 379 | if ($model) { 380 | $revertedAttributes = array_except($model->attributes,[$this->primaryKey]); 381 | $this->forceFill($revertedAttributes); 382 | $this->save(); 383 | } 384 | } 385 | 386 | } 387 | -------------------------------------------------------------------------------- /src/VersionDiffer.php: -------------------------------------------------------------------------------- 1 | ignoredFields, $ignoredFields); 37 | 38 | $changes = []; 39 | $leftAttributes = array_except($left->getAttributes(), $ignoredFields); 40 | $rightAttributes = array_except($right->getAttributes(), $ignoredFields); 41 | 42 | $differences = array_diff($leftAttributes, $rightAttributes); 43 | 44 | foreach ($differences as $key => $value) { 45 | $changes[$key][0] = $leftAttributes[$key]; 46 | $changes[$key][1] = $rightAttributes[$key]; 47 | $changes[$key]['left'] = $leftAttributes[$key]; 48 | $changes[$key]['right'] = $rightAttributes[$key]; 49 | } 50 | 51 | return $changes; 52 | } 53 | } 54 | --------------------------------------------------------------------------------