├── .gitignore ├── src ├── AppServiceProvider.php ├── Exceptions │ └── Versioning.php ├── Models │ └── Versioning.php └── Traits │ └── Versionable.php ├── .styleci.yml ├── .github └── issue_template.md ├── codesize.xml ├── database └── migrations │ └── 2017_01_01_009000_create_versionings_table.php ├── composer.json ├── LICENSE ├── tests └── features │ └── VersioningTest.php └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /src/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__.'/../database/migrations'); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | risky: true 2 | 3 | preset: laravel 4 | 5 | enabled: 6 | - strict 7 | - unalign_double_arrow 8 | - phpdoc_order 9 | - phpdoc_separation 10 | 11 | disabled: 12 | - short_array_syntax 13 | 14 | finder: 15 | exclude: 16 | - "public" 17 | - "resources" 18 | - "tests" 19 | name: 20 | - "*.php" 21 | -------------------------------------------------------------------------------- /src/Exceptions/Versioning.php: -------------------------------------------------------------------------------- 1 | 'integer']; 12 | 13 | public function versionable() 14 | { 15 | return $this->morphTo(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | 2 | This is a **bug | feature request**. 3 | 4 | 5 | ### Prerequisites 6 | * [ ] Are you running the latest version? 7 | * [ ] Are you reporting to the correct repository? 8 | * [ ] Did you check the documentation? 9 | * [ ] Did you perform a cursory search? 10 | 11 | ### Description 12 | 13 | 14 | ### Steps to Reproduce 15 | 20 | 21 | ### Expected behavior 22 | 23 | 24 | ### Actual behavior 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /codesize.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | custom rules 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /database/migrations/2017_01_01_009000_create_versionings_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 13 | 14 | $table->string('versionable_type'); 15 | $table->unsignedBigInteger('versionable_id'); 16 | 17 | $table->integer('version'); 18 | 19 | $table->timestamps(); 20 | 21 | $table->unique(['versionable_type', 'versionable_id']); 22 | }); 23 | } 24 | 25 | public function down() 26 | { 27 | Schema::dropIfExists('versionings'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-enso/versioning", 3 | "description": "Prevents update conflicts using the optimistic lock pattern in Laravel", 4 | "keywords": [ 5 | "laravel-enso", 6 | "versioning", 7 | "trait", 8 | "optimistic-lock" 9 | ], 10 | "homepage": "https://github.com/laravel-enso/versioning", 11 | "type": "library", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Adrian Ocneanu", 16 | "email": "aocneanu@gmail.com", 17 | "homepage": "https://laravel-enso.com", 18 | "role": "Developer" 19 | } 20 | ], 21 | "require": { 22 | "php": ">=8.0", 23 | "laravel/framework": "^8.0|^9.0" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "LaravelEnso\\Versioning\\": "src/" 28 | } 29 | }, 30 | "extra": { 31 | "laravel": { 32 | "providers": [ 33 | "LaravelEnso\\Versioning\\AppServiceProvider" 34 | ], 35 | "aliases": {} 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 laravel-enso 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 | -------------------------------------------------------------------------------- /tests/features/VersioningTest.php: -------------------------------------------------------------------------------- 1 | withoutExceptionHandling(); 19 | 20 | $this->createTestModelsTable(); 21 | } 22 | 23 | /** @test */ 24 | public function adds_version_when_creating() 25 | { 26 | $model = VersioningTestModel::create(['name' => 'testModel']); 27 | 28 | $this->assertEquals(1, $model->versioning->version); 29 | } 30 | 31 | /** @test */ 32 | public function increases_version_when_updating() 33 | { 34 | $model = VersioningTestModel::create(['name' => 'testModel']); 35 | 36 | $model->update(['name' => 'updated']); 37 | 38 | $this->assertEquals(2, $model->versioning->version); 39 | } 40 | 41 | /** @test */ 42 | public function throws_error_when_version_is_wrong() 43 | { 44 | VersioningTestModel::create(['name' => 'testModel']); 45 | 46 | $model = VersioningTestModel::first(); 47 | 48 | $secondModel = VersioningTestModel::first(); 49 | 50 | $model->update(['name' => 'updated']); 51 | 52 | $this->expectException(Versioning::class); 53 | 54 | $secondModel->update(['name' => 'testModel2']); 55 | } 56 | 57 | private function createTestModelsTable() 58 | { 59 | Schema::create('versioning_test_models', static function ($table) { 60 | $table->increments('id'); 61 | $table->string('name'); 62 | $table->timestamps(); 63 | }); 64 | } 65 | } 66 | 67 | class VersioningTestModel extends Model 68 | { 69 | use Versionable; 70 | 71 | protected $fillable = ['name']; 72 | } 73 | 74 | class CustomVersioningTestModel extends Model 75 | { 76 | use Versionable; 77 | 78 | protected $versioningAttribute = 'custom_field'; 79 | 80 | protected $fillable = ['name']; 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Versioning 2 | 3 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/1d5b542a2d014afea54a5bcf315e0d9c)](https://www.codacy.com/gh/laravel-enso/versioning?utm_source=github.com&utm_medium=referral&utm_content=laravel-enso/versioning&utm_campaign=Badge_Grade) 4 | [![StyleCI](https://github.styleci.io/repos/134861936/shield?branch=master)](https://github.styleci.io/repos/134861936) 5 | [![License](https://poser.pugx.org/laravel-enso/versioning/license)](https://packagist.org/packages/laravel-enso/versioning) 6 | [![Total Downloads](https://poser.pugx.org/laravel-enso/versioning/downloads)](https://packagist.org/packages/laravel-enso/versioning) 7 | [![Latest Stable Version](https://poser.pugx.org/laravel-enso/versioning/version)](https://packagist.org/packages/laravel-enso/versioning) 8 | 9 | Prevents update conflicts using the optimistic lock pattern in Laravel 10 | 11 | This package can work independently of the [Enso](https://github.com/laravel-enso/Enso) ecosystem. 12 | 13 | For live examples and demos, you may visit [laravel-enso.com](https://www.laravel-enso.com) 14 | 15 | ## Installation 16 | 17 | 1. install the package `composer require laravel-enso/versioning` 18 | 2. run the migrations 19 | 3. use the `Versionable` trait on the models you want versioning on. 20 | 21 | By default, the version value is kept in a 'version' attribute, but this can be customized (see below). 22 | 23 | ## Features 24 | 25 | - the package creates a `versionings` table where it holds versions for all the versionable models 26 | - by using the `Versionable` trait on a model, versioning is handled automatically 27 | - by default the trait appends a `version` attribute after the model is retrieved, used for tracking versions and expects the same attribute to be present on the model when the update is called 28 | - the default versioning attribute can be customized by using `protected $versioningAttribute = 'customVersionAttribte'` on the model 29 | - the trait can be used on models that already have records in the database, the versioning starts with the first retrieval of those models 30 | - when a versionable model is deleted, its versioning is deleted also. If the model uses `SoftDeletes`, the versioning is not deleted, unless doing a `forceDelete` 31 | - throws a `ConflictHttpException` if the version does not match on update 32 | - tests are included with the package 33 | 34 | ## Configuration & Usage 35 | 36 | Be sure to check out the full documentation for this package available at [docs.laravel-enso.com](https://docs.laravel-enso.com/backend/versioning.html) 37 | 38 | ## Contributions 39 | 40 | are welcome. Pull requests are great, but issues are good too. 41 | 42 | ## License 43 | 44 | This package is released under the MIT license. 45 | -------------------------------------------------------------------------------- /src/Traits/Versionable.php: -------------------------------------------------------------------------------- 1 | $model->startVersioning()); 18 | 19 | self::versioningRetrieved(fn ($model) => $model->initVersion()); 20 | 21 | self::updating(fn ($model) => $model->checkOrInitVersion()); 22 | 23 | self::updated(fn ($model) => $model->incrementVersion()); 24 | 25 | self::deleted(fn ($model) => $model->deleteVersioning()); 26 | } 27 | 28 | public static function versioningRetrieved($callback) 29 | { 30 | static::registerModelEvent('versioningRetrieved', $callback); 31 | } 32 | 33 | public function setRelation($relation, $value) 34 | { 35 | $this->relations[$relation] = $value; 36 | 37 | if ($relation === 'versioning') { 38 | $this->fireModelEvent('versioningRetrieved', false); 39 | } 40 | 41 | return $this; 42 | } 43 | 44 | public function initializeVersionable() 45 | { 46 | $this->with[] = 'versioning'; 47 | $this->observables[] = 'relationsRetrieved'; 48 | } 49 | 50 | public function versioning() 51 | { 52 | return $this->morphOne(Versioning::class, 'versionable'); 53 | } 54 | 55 | public function checkVersion($version) 56 | { 57 | if ($this->{$this->versioningAttribute()} !== $version) { 58 | DB::rollBack(); 59 | throw VersioningException::recordModified(); 60 | } 61 | 62 | return $this; 63 | } 64 | 65 | public function lockWithoutEvents() 66 | { 67 | DB::table($this->getTable())->lock() 68 | ->where($this->getKeyName(), $this->getKey()) 69 | ->first(); 70 | } 71 | 72 | public function startVersioning() 73 | { 74 | DB::transaction(fn () => $this->createVersion()); 75 | } 76 | 77 | public function usesSoftDelete() 78 | { 79 | return in_array(SoftDeletes::class, class_uses(self::class)); 80 | } 81 | 82 | private function createVersion() 83 | { 84 | $this->lockWithoutEvents(); 85 | 86 | $startsAt = 1; 87 | 88 | try { 89 | $this->versioning()->save( 90 | new Versioning(['version' => $startsAt]) 91 | ); 92 | } catch (Exception) { 93 | throw VersioningException::recordModified(); 94 | } 95 | 96 | $this->{$this->versioningAttribute()} = $startsAt; 97 | } 98 | 99 | private function incrementVersion() 100 | { 101 | $this->versioning->increment('version'); 102 | $this->{$this->versioningAttribute()} = $this->versioning->version; 103 | 104 | DB::commit(); 105 | } 106 | 107 | private function initVersion() 108 | { 109 | $version = $this->relations['versioning']?->version; 110 | $this->{$this->versioningAttribute()} = $version; 111 | } 112 | 113 | private function deleteVersioning() 114 | { 115 | if (! $this->usesSoftDelete() || $this->isForceDeleting()) { 116 | $this->versioning()->delete(); 117 | } 118 | } 119 | 120 | private function checkOrInitVersion() 121 | { 122 | DB::beginTransaction(); 123 | 124 | $versioning = $this->versioning()->lock()->first(); 125 | 126 | if ($versioning) { 127 | $this->checkVersion($versioning->version); 128 | } else { 129 | $this->startVersioning(); 130 | $versioning = $this->versioning()->lock()->first(); 131 | } 132 | 133 | $this->relations['versioning'] = $versioning; 134 | 135 | unset($this->{$this->versioningAttribute()}); 136 | } 137 | 138 | private function versioningAttribute() 139 | { 140 | return property_exists($this, 'versioningAttribute') 141 | ? $this->versioningAttribute 142 | : 'version'; 143 | } 144 | } 145 | --------------------------------------------------------------------------------