diff --git a/composer.json b/composer.json index 8f9b850..d92eaba 100644 --- a/composer.json +++ b/composer.json @@ -1,22 +1,21 @@ { - "name": ":vendor_slug/:package_slug", - "description": ":package_description", + "name": "stafe/optimistic-locking", + "description": "Optimistic locking utilities for Laravel Eloquent models.", "keywords": [ - ":vendor_name", "laravel", - ":package_slug" + "optimistic-locking" ], - "homepage": "https://github.com/:vendor_slug/:package_slug", + "homepage": "https://example.com/optimistic-locking", "license": "MIT", "authors": [ { - "name": ":author_name", - "email": "author@domain.com", + "name": "Package Developer", + "email": "developer@example.com", "role": "Developer" } ], "require": { - "php": "^8.4", + "php": "^8.1", "spatie/laravel-package-tools": "^1.16", "illuminate/contracts": "^10.0||^11.0||^12.0" }, @@ -35,13 +34,13 @@ }, "autoload": { "psr-4": { - "VendorName\\Skeleton\\": "src/", - "VendorName\\Skeleton\\Database\\Factories\\": "database/factories/" + "Stafe\\OptimisticLocking\\": "src/", + "Stafe\\OptimisticLocking\\Database\\Factories\\": "database/factories/" } }, "autoload-dev": { "psr-4": { - "VendorName\\Skeleton\\Tests\\": "tests/", + "Stafe\\OptimisticLocking\\Tests\\": "tests/", "Workbench\\App\\": "workbench/app/" } }, @@ -63,10 +62,10 @@ "extra": { "laravel": { "providers": [ - "VendorName\\Skeleton\\SkeletonServiceProvider" + "Stafe\\OptimisticLocking\\OptimisticLockingServiceProvider" ], "aliases": { - "Skeleton": "VendorName\\Skeleton\\Facades\\Skeleton" + "OptimisticLocking": "Stafe\\OptimisticLocking\\Facades\\OptimisticLocking" } } }, diff --git a/config/optimistic.php b/config/optimistic.php new file mode 100644 index 0000000..00975ba --- /dev/null +++ b/config/optimistic.php @@ -0,0 +1,7 @@ + 'lock_version', + 'start_value' => 1, + 'diff_max_len' => 250, +]; diff --git a/config/skeleton.php b/config/skeleton.php deleted file mode 100644 index 7e74186..0000000 --- a/config/skeleton.php +++ /dev/null @@ -1,6 +0,0 @@ - - + tests diff --git a/src/Commands/LockCommand.php b/src/Commands/LockCommand.php new file mode 100644 index 0000000..30a5a02 --- /dev/null +++ b/src/Commands/LockCommand.php @@ -0,0 +1,35 @@ +option('apply')) { + foreach (\File::allFiles(app_path('Models')) as $file) { + $class = 'App\\Models\\'.pathinfo($file, PATHINFO_FILENAME); + if (is_subclass_of($class, \Illuminate\Database\Eloquent\Model::class)) { + if (! \Schema::hasColumn((new $class)->getTable(), $column)) { + \Schema::table((new $class)->getTable(), function ($table) use ($column) { + $table->unsignedInteger($column)->nullable(); + }); + $this->info("Added {$column} column to ".(new $class)->getTable()); + } + } + } + + return self::SUCCESS; + } + $this->comment('No action'); + + return self::SUCCESS; + } +} diff --git a/src/Commands/SkeletonCommand.php b/src/Commands/SkeletonCommand.php deleted file mode 100644 index 3e5f628..0000000 --- a/src/Commands/SkeletonCommand.php +++ /dev/null @@ -1,19 +0,0 @@ -comment('All done'); - - return self::SUCCESS; - } -} diff --git a/src/Facades/Skeleton.php b/src/Facades/OptimisticLocking.php similarity index 53% rename from src/Facades/Skeleton.php rename to src/Facades/OptimisticLocking.php index 571a498..4e60230 100644 --- a/src/Facades/Skeleton.php +++ b/src/Facades/OptimisticLocking.php @@ -1,16 +1,16 @@ name('skeleton') - ->hasConfigFile() - ->hasViews() - ->hasMigration('create_migration_table_name_table') - ->hasCommand(SkeletonCommand::class); + ->name('optimistic-locking') + ->hasConfigFile('optimistic') + ->hasCommand(LockCommand::class); } } diff --git a/src/Skeleton.php b/src/Skeleton.php deleted file mode 100755 index 34c7194..0000000 --- a/src/Skeleton.php +++ /dev/null @@ -1,5 +0,0 @@ -getDirty(); + unset($changes[$column]); + + $original = $model->newQuery()->whereKey($model->getKey())->first(); + $diff = []; + foreach ($changes as $key => $value) { + $old = data_get($original, $key); + $new = $value; + if (is_string($old)) { + $old = mb_strimwidth($old, 0, $maxLen, '...'); + } + if (is_string($new)) { + $new = mb_strimwidth($new, 0, $maxLen, '...'); + } + $diff[$key] = ['old' => $old, 'new' => $new]; + } + $this->diff = $diff; + } + + public function diff(): array + { + return $this->diff; + } +} diff --git a/src/Traits/OptimisticLocking.php b/src/Traits/OptimisticLocking.php new file mode 100644 index 0000000..46021af --- /dev/null +++ b/src/Traits/OptimisticLocking.php @@ -0,0 +1,62 @@ +getOptimisticLockColumn(); + if (! $model->isDirty($column)) { + $model->{$column} = config('optimistic.start_value', 1); + } + }); + + static::updating(function (Model $model) { + $column = $model->getOptimisticLockColumn(); + $model->{$column} = ($model->getOriginal($column) ?? 0) + 1; + }); + } + + public function getOptimisticLockColumn(): string + { + return config('optimistic.column', 'lock_version'); + } + + protected function performUpdate(Builder $query) + { + if ($this->fireModelEvent('updating') === false) { + return false; + } + + if ($this->usesTimestamps()) { + $this->updateTimestamps(); + } + + $column = $this->getOptimisticLockColumn(); + $current = $this->getOriginal($column); + $query->where($column, $current); + + $dirty = $this->getDirtyForUpdate(); + $dirty[$column] = $this->{$column}; + + if (count($dirty) > 0) { + $updated = $this->setKeysForSaveQuery($query)->update($dirty); + + if ($updated === 0) { + throw new StaleModelException($this, $current); + } + + $this->syncChanges(); + + $this->fireModelEvent('updated', false); + } + + return true; + } +} diff --git a/tests/Models/Post.php b/tests/Models/Post.php new file mode 100644 index 0000000..7cea43a --- /dev/null +++ b/tests/Models/Post.php @@ -0,0 +1,15 @@ + 'A']); + expect($post->lock_version)->toBe(1); + $post->title = 'B'; + $post->save(); + expect($post->lock_version)->toBe(2); +}); + +it('throws exception on stale update', function () { + $post = Post::create(['title' => 'A']); + $first = Post::find($post->id); + $second = Post::find($post->id); + + $first->title = 'B'; + $first->save(); + + $second->title = 'C'; + expect(function () use ($second) { + $second->save(); + })->toThrow(StaleModelException::class); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 7fe1500..2d899b7 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,5 @@ in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php index 220551b..fa62fcf 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,10 +1,10 @@ 'VendorName\\Skeleton\\Database\\Factories\\'.class_basename($modelName).'Factory' + fn (string $modelName) => 'Stafe\\OptimisticLocking\\Database\\Factories\\'.class_basename($modelName).'Factory' ); } protected function getPackageProviders($app) { return [ - SkeletonServiceProvider::class, + OptimisticLockingServiceProvider::class, ]; } @@ -28,10 +28,8 @@ public function getEnvironmentSetUp($app) { config()->set('database.default', 'testing'); - /* - foreach (\Illuminate\Support\Facades\File::allFiles(__DIR__ . '/database/migrations') as $migration) { + foreach (\Illuminate\Support\Facades\File::allFiles(__DIR__.'/database/migrations') as $migration) { (include $migration->getRealPath())->up(); - } - */ + } } } diff --git a/tests/database/migrations/create_posts_table.php b/tests/database/migrations/create_posts_table.php new file mode 100644 index 0000000..4235f8b --- /dev/null +++ b/tests/database/migrations/create_posts_table.php @@ -0,0 +1,17 @@ +id(); + $table->string('title'); + $table->unsignedInteger(config('optimistic.column', 'lock_version'))->nullable(); + }); + } +};