├── LICENSE ├── README.md ├── composer.json └── src ├── OptimisticLocking.php └── StaleModelLockingException.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Reza Shadman 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 | # Laravel Optimistic Locking 2 | ![Build Status](http://img.shields.io/travis/reshadman/laravel-optimistic-locking/master.png?style=flat-square) 3 | 4 | Adds optimistic locking feature to Eloquent models. 5 | 6 | ## Installation 7 | ```bash 8 | composer require reshadman/laravel-optimistic-locking 9 | ``` 10 | 11 | > This package supports Laravel 5.5.*, 5.6.*, 5.7.*, 5.8.*, and 6.* . 12 | 13 | ## Usage 14 | 15 | ### Basic usage 16 | use the `\Reshadman\OptimisticLocking\OptimisticLocking` trait 17 | in your model: 18 | 19 | ```php 20 | integer('lock_version')->unsigned()->nullable(); 32 | ``` 33 | 34 | Then you are ready to go, if the same resource is edited by two 35 | different processes **CONCURRENTLY** then the following exception 36 | will be raised: 37 | 38 | ```php 39 | lock_version}}" 51 | ``` 52 | and in controller: 53 | ```php 54 | lock_version = request('lock_version'); 62 | $post->save(); 63 | // You can also define more implicit reusable methods in your model like Model::saveWithVersion(...$args); 64 | // or just override the default Model::save(...$args); method which accepts $options 65 | // Then automatically read the lock version from Request and set into the model. 66 | } 67 | } 68 | ``` 69 | 70 | So if two authors are editing the same content concurrently, 71 | you can keep track of your **Read State**, and ask the second 72 | author to rewrite his changes. 73 | 74 | ### Disabling and enabling optimistic locking 75 | You can disable and enable optimistic locking for a specific 76 | instance: 77 | 78 | ```php 79 | disableLocking(); 81 | $blogPost->enableLocking(); 82 | ``` 83 | 84 | By default optimistic locking is enabled when you use 85 | `OptimisticLocking` trait in your model, to alter the default 86 | behaviour you can set the lock strictly to `false`: 87 | 88 | ```php 89 | enableLocking();` 98 | 99 | ### Use a different column for tracking version 100 | By default the `lock_version` column is used for tracking 101 | version, you can alter that by overriding the following method 102 | of the trait: 103 | 104 | ```php 105 | where('id', $this->id) 148 | ->where('lock_version', $this->lock_version) 149 | ->update($changes); 150 | ``` 151 | 152 | If the resource has been updated before your update attempt, then the above will simply 153 | update **no** records and it means that the model has been updated before 154 | current attempt or it has been deleted. 155 | 156 | ### Why don't we use `updated_at` for tracking changes? 157 | Because they may remain the same during two concurrent updates. 158 | 159 | ## Running tests 160 | Clone the repo, perform a composer install and run: 161 | 162 | ```vendor/bin/phpunit``` 163 | 164 | ## License 165 | 166 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 167 | ense (MIT). Please see License File for more information. 168 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reshadman/laravel-optimistic-locking", 3 | "description": "Adds optimistic locking feature to eloquent models.", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Reza Shadman", 9 | "email": "pcfeeler@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^7.1", 14 | "illuminate/database": "~5.5.0|~5.6.0|~5.7.0|~5.8.0|^6.0", 15 | "illuminate/support": "~5.5.0|~5.6.0|~5.7.0|~5.8.0|^6.0" 16 | }, 17 | "require-dev": { 18 | "ext-pdo_sqlite": "*", 19 | "mockery/mockery": "^1.0.0", 20 | "orchestra/testbench": "~3.5.0|~3.6.0|~3.8.0|^4.0|^5.0", 21 | "phpunit/phpunit" : "^7.0|^8.0|^9.0" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "Reshadman\\OptimisticLocking\\": "src" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "Reshadman\\OptimisticLocking\\Tests\\": "tests" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/OptimisticLocking.php: -------------------------------------------------------------------------------- 1 | currentLockVersion() === null) { 27 | 28 | $model->{static::lockVersionColumn()} = static::defaultLockVersion(); 29 | 30 | } 31 | 32 | return $model; 33 | }); 34 | } 35 | 36 | /** 37 | * Perform a model update operation respecting optimistic locking. 38 | * If the lock fails it will throw a "StaleModelLockingException" 39 | * 40 | * @param Builder $query 41 | * @return bool 42 | */ 43 | protected function performUpdate(Builder $query) 44 | { 45 | // If the updating event returns false, we will cancel the update operation so 46 | // developers can hook Validation systems into their models and cancel this 47 | // operation if the model does not pass validation. Otherwise, we update. 48 | if ($this->fireModelEvent('updating') === false) { 49 | return false; 50 | } 51 | 52 | // First we need to create a fresh query instance and touch the creation and 53 | // update timestamp on the model which are maintained by us for developer 54 | // convenience. Then we will just continue saving the model instances. 55 | if ($this->usesTimestamps()) { 56 | $this->updateTimestamps(); 57 | } 58 | 59 | // Once we have run the update operation, we will fire the "updated" event for 60 | // this model instance. This will allow developers to hook into these after 61 | // models are updated, giving them a chance to do any special processing. 62 | $dirty = $this->getDirty(); 63 | 64 | if (count($dirty) > 0) { 65 | 66 | $versionColumn = static::lockVersionColumn(); 67 | 68 | $this->setKeysForSaveQuery($query); 69 | 70 | // If model locking is enabled, the lock version check constraint is 71 | // added to the update query, as every update on the model increments the version 72 | // by exactly "1" we will increment the value by one for update, then. 73 | if ($this->lockingEnabled()) { 74 | $query->where($versionColumn, '=', $this->currentLockVersion()); 75 | } 76 | 77 | $beforeUpdateVersion = $this->currentLockVersion(); 78 | 79 | $this->setAttribute($versionColumn, $newVersion = $beforeUpdateVersion + 1); 80 | $dirty[$versionColumn] = $newVersion; 81 | 82 | // If there is no record affected by our update query, 83 | // It means that the record has been updated by another process, 84 | // Or has been deleted, as we treat "delete" as an act of update 85 | // we throw the exception in this situation anyway. 86 | $affected = $query->update($dirty); 87 | 88 | if ($affected === 0) { 89 | $this->setAttribute($versionColumn, $beforeUpdateVersion); 90 | 91 | throw new StaleModelLockingException("Model has been changed during update."); 92 | } 93 | 94 | $this->fireModelEvent('updated', false); 95 | 96 | $this->syncChanges(); 97 | } 98 | 99 | return true; 100 | } 101 | 102 | /** 103 | * Name of the lock version column. 104 | * 105 | * @return string 106 | */ 107 | protected static function lockVersionColumn() 108 | { 109 | return 'lock_version'; 110 | } 111 | 112 | /** 113 | * Current lock version value. 114 | * 115 | * @return int 116 | */ 117 | public function currentLockVersion() 118 | { 119 | return $this->getAttribute(static::lockVersionColumn()); 120 | } 121 | 122 | /** 123 | * Default lock version value. 124 | * 125 | * @return int 126 | */ 127 | protected static function defaultLockVersion() 128 | { 129 | return 1; 130 | } 131 | 132 | /** 133 | * Indicates that optimistic locking is enabled for this model 134 | * instance or not. 135 | * 136 | * @return bool 137 | */ 138 | protected function lockingEnabled() 139 | { 140 | return $this->lock === null ? true : $this->lock; 141 | } 142 | 143 | /** 144 | * Disables optimistic locking for this model instance. 145 | * 146 | * @return $this 147 | */ 148 | protected function disableLocking() 149 | { 150 | $this->lock = false; 151 | return $this; 152 | } 153 | 154 | /** 155 | * Enables optimistic locking for this model instance. 156 | * 157 | * @return $this 158 | */ 159 | public function enableLocking() 160 | { 161 | $this->lock = true; 162 | return $this; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/StaleModelLockingException.php: -------------------------------------------------------------------------------- 1 |