From 452b029a486af1bfb0d3f422f4874f5a538242c2 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Fri, 2 Jul 2021 12:19:21 +0100 Subject: [PATCH 1/9] Adds possibility of having "Prunable" models --- .../Database/Console/PruneCommand.php | 92 ++++++++++ .../Eloquent/Concerns/PrunableQueries.php | 18 ++ .../Database/Eloquent/MassPrunable.php | 38 +++++ src/Illuminate/Database/Eloquent/Prunable.php | 44 +++++ .../Database/Events/ModelsPruned.php | 33 ++++ .../Providers/ArtisanServiceProvider.php | 14 ++ tests/Database/PruneCommandTest.php | 109 ++++++++++++ .../Database/EloquentMassPrunableTest.php | 122 ++++++++++++++ .../Database/EloquentPrunableTest.php | 159 ++++++++++++++++++ 9 files changed, 629 insertions(+) create mode 100644 src/Illuminate/Database/Console/PruneCommand.php create mode 100644 src/Illuminate/Database/Eloquent/Concerns/PrunableQueries.php create mode 100644 src/Illuminate/Database/Eloquent/MassPrunable.php create mode 100644 src/Illuminate/Database/Eloquent/Prunable.php create mode 100644 src/Illuminate/Database/Events/ModelsPruned.php create mode 100644 tests/Database/PruneCommandTest.php create mode 100644 tests/Integration/Database/EloquentMassPrunableTest.php create mode 100644 tests/Integration/Database/EloquentPrunableTest.php diff --git a/src/Illuminate/Database/Console/PruneCommand.php b/src/Illuminate/Database/Console/PruneCommand.php new file mode 100644 index 000000000000..a9bf96a823d8 --- /dev/null +++ b/src/Illuminate/Database/Console/PruneCommand.php @@ -0,0 +1,92 @@ +listen(ModelsPruned::class, function ($event) { + $amount = $event->amount; + $model = $event->model; + + $this->info("$amount [$model] records have been pruned."); + }); + + $this->models()->each(function ($model) { + $total = $this->isPrunable($model) ? (new $model)->pruneAll() : 0; + + if ($total == 0) { + $this->info("No prunable [$model] records found."); + } + }); + + $events->forget(ModelsPruned::class); + } + + /** + * Dertermine the models that should be pruned. + * + * @return array + */ + protected function models() + { + if (! empty($models = $this->option('model'))) { + return collect($models); + } + + return collect((new Finder)->in(app_path('Models'))->files()) + ->map(function ($model) { + $namespace = $this->laravel->getNamespace(); + + return $namespace.str_replace( + ['/', '.php'], + ['\\', ''], + Str::after($model->getRealPath(), realpath(app_path()).DIRECTORY_SEPARATOR) + ); + })->filter(function ($model) { + return $this->isPrunable($model); + })->values(); + } + + /** + * Checks if the given model class is prunable. + * + * @param string $model + * @return bool + */ + protected function isPrunable($model) + { + $uses = class_uses_recursive($model); + + return in_array(Prunable::class, $uses) || in_array(MassPrunable::class, $uses); + } +} diff --git a/src/Illuminate/Database/Eloquent/Concerns/PrunableQueries.php b/src/Illuminate/Database/Eloquent/Concerns/PrunableQueries.php new file mode 100644 index 000000000000..ba8e23825acd --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Concerns/PrunableQueries.php @@ -0,0 +1,18 @@ +prunable(), function ($query) { + $query->when(! $query->getQuery()->limit, function ($query) { + $query->limit(1000); + }); + }); + + $total = 0; + + do { + $total += $count = in_array(SoftDeletes::class, class_uses_recursive(get_called_class())) + ? $query->forceDelete() + : $query->delete(); + + if ($count > 0) { + event(new ModelsPruned(static::class, $total)); + } + } while ($count > 0); + + return $total; + } +} diff --git a/src/Illuminate/Database/Eloquent/Prunable.php b/src/Illuminate/Database/Eloquent/Prunable.php new file mode 100644 index 000000000000..00c50f778813 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Prunable.php @@ -0,0 +1,44 @@ +prunable() + ->when(in_array(SoftDeletes::class, class_uses_recursive(get_called_class())), function ($query) { + $query->withTrashed(); + })->chunkById(1000, function ($models) use (&$total) { + $models->each->prune(); + $total += $models->count(); + + event(new ModelsPruned(static::class, $total)); + }); + + return $total; + } + + /** + * Prune the model in the database. + * + * @return bool|null + */ + public function prune() + { + return in_array(SoftDeletes::class, class_uses_recursive(get_called_class())) + ? $this->forceDelete() + : $this->delete(); + } +} diff --git a/src/Illuminate/Database/Events/ModelsPruned.php b/src/Illuminate/Database/Events/ModelsPruned.php new file mode 100644 index 000000000000..6a3f9d5d9df5 --- /dev/null +++ b/src/Illuminate/Database/Events/ModelsPruned.php @@ -0,0 +1,33 @@ +model = $model; + $this->amount = $amount; + } +} diff --git a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php index 5728f23c1f58..9496dcea3730 100755 --- a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php @@ -15,6 +15,7 @@ use Illuminate\Database\Console\DbCommand; use Illuminate\Database\Console\DumpCommand; use Illuminate\Database\Console\Factories\FactoryMakeCommand; +use Illuminate\Database\Console\PruneCommand; use Illuminate\Database\Console\Seeds\SeedCommand; use Illuminate\Database\Console\Seeds\SeederMakeCommand; use Illuminate\Database\Console\WipeCommand; @@ -94,6 +95,7 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid 'ConfigCache' => 'command.config.cache', 'ConfigClear' => 'command.config.clear', 'Db' => DbCommand::class, + 'DbPrune' => 'command.db.prune', 'DbWipe' => 'command.db.wipe', 'Down' => 'command.down', 'Environment' => 'command.environment', @@ -352,6 +354,18 @@ protected function registerDbCommand() $this->app->singleton(DbCommand::class); } + /** + * Register the command. + * + * @return void + */ + protected function registerDbPruneCommand() + { + $this->app->singleton('command.db.prune', function ($app) { + return new PruneCommand($app['events']); + }); + } + /** * Register the command. * diff --git a/tests/Database/PruneCommandTest.php b/tests/Database/PruneCommandTest.php new file mode 100644 index 000000000000..5bc14742c16f --- /dev/null +++ b/tests/Database/PruneCommandTest.php @@ -0,0 +1,109 @@ +singleton(DispatcherContract::class, function () { + return new Dispatcher(); + }); + + $container->alias(DispatcherContract::class, 'events'); + } + + public function testPrunableModelWithPrunableRecords() + { + $output = $this->artisan(['--model' => PrunableTestModelWithPrunableRecords::class]); + + $this->assertEquals(<<<'EOF' +10 [Illuminate\Tests\Database\PrunableTestModelWithPrunableRecords] records have been pruned. +20 [Illuminate\Tests\Database\PrunableTestModelWithPrunableRecords] records have been pruned. + +EOF, $output->fetch()); + } + + public function testPrunableTestModelWithoutPrunableRecords() + { + $output = $this->artisan(['--model' => PrunableTestModelWithoutPrunableRecords::class]); + + $this->assertEquals(<<<'EOF' +No prunable [Illuminate\Tests\Database\PrunableTestModelWithoutPrunableRecords] records found. + +EOF, $output->fetch()); + } + + public function testNonPrunableTest() + { + $output = $this->artisan(['--model' => NonPrunableTestModel::class]); + + $this->assertEquals(<<<'EOF' +No prunable [Illuminate\Tests\Database\NonPrunableTestModel] records found. + +EOF, $output->fetch()); + } + + protected function artisan($arguments) + { + $input = new ArrayInput($arguments); + $output = new BufferedOutput; + + tap(new PruneCommand()) + ->setLaravel(Container::getInstance()) + ->run($input, $output); + + return $output; + } + + public function tearDown(): void + { + parent::tearDown(); + + Container::setInstance(null); + } +} + +class PrunableTestModelWithPrunableRecords extends Model +{ + use MassPrunable; + + public function pruneAll() + { + event(new ModelsPruned(static::class, 10)); + event(new ModelsPruned(static::class, 20)); + + return 20; + } +} + +class PrunableTestModelWithoutPrunableRecords extends Model +{ + use Prunable; + + public function pruneAll() + { + return 0; + } +} + +class NonPrunableTestModel extends Model +{ + // .. +} diff --git a/tests/Integration/Database/EloquentMassPrunableTest.php b/tests/Integration/Database/EloquentMassPrunableTest.php new file mode 100644 index 000000000000..7881cf4cb559 --- /dev/null +++ b/tests/Integration/Database/EloquentMassPrunableTest.php @@ -0,0 +1,122 @@ +singleton(Dispatcher::class, function () { + return m::mock(Dispatcher::class); + }); + + $container->alias(Dispatcher::class, 'events'); + + collect([ + 'mass_prunable_test_models', + 'mass_prunable_soft_delete_test_models', + 'mass_prunable_test_model_missing_prunable_methods', + ])->each(function ($table) { + Schema::create($table, function (Blueprint $table) { + $table->increments('id'); + $table->softDeletes(); + $table->boolean('pruned')->default(false); + $table->timestamps(); + }); + }); + } + + public function testPrunableMethodMustBeImplemented() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'The prunable method must be implemented on the model class.', + ); + + MassPrunableTestModelMissingPrunableMethod::create()->pruneAll(); + } + + public function testPrunesRecords() + { + app('events') + ->shouldReceive('dispatch') + ->times(2) + ->with(m::type(ModelsPruned::class)); + + MassPrunableTestModel::insert(collect(range(1, 5000))->map(function ($id) { + return ['id' => $id]; + })->all()); + + $count = (new MassPrunableTestModel)->pruneAll(); + + $this->assertEquals(1500, $count); + $this->assertEquals(3500, MassPrunableTestModel::count()); + } + + public function testPrunesSoftDeletedRecords() + { + app('events') + ->shouldReceive('dispatch') + ->times(3) + ->with(m::type(ModelsPruned::class)); + + MassPrunableSoftDeleteTestModel::insert(collect(range(1, 5000))->map(function ($id) { + return ['id' => $id, 'deleted_at' => now()]; + })->all()); + + $count = (new MassPrunableSoftDeleteTestModel)->pruneAll(); + + $this->assertEquals(3000, $count); + $this->assertEquals(0, MassPrunableSoftDeleteTestModel::count()); + $this->assertEquals(2000, MassPrunableSoftDeleteTestModel::withTrashed()->count()); + } + + public function tearDown(): void + { + parent::tearDown(); + + Container::setInstance(null); + + m::close(); + } +} + +class MassPrunableTestModel extends Model +{ + use MassPrunable; + + public function prunable() + { + return $this->where('id', '<=', 1500); + } +} + +class MassPrunableSoftDeleteTestModel extends Model +{ + use MassPrunable, SoftDeletes; + + public function prunable() + { + return $this->where('id', '<=', 3000); + } +} + +class MassPrunableTestModelMissingPrunableMethod extends Model +{ + use MassPrunable; +} diff --git a/tests/Integration/Database/EloquentPrunableTest.php b/tests/Integration/Database/EloquentPrunableTest.php new file mode 100644 index 000000000000..f9c60706681f --- /dev/null +++ b/tests/Integration/Database/EloquentPrunableTest.php @@ -0,0 +1,159 @@ +singleton(Dispatcher::class, function () { + return m::mock(Dispatcher::class); + }); + + $container->alias(Dispatcher::class, 'events'); + + collect([ + 'prunable_test_models', + 'prunable_soft_delete_test_models', + 'prunable_test_model_missing_prunable_methods', + 'prunable_with_custom_prune_method_test_models', + ])->each(function ($table) { + Schema::create($table, function (Blueprint $table) { + $table->increments('id'); + $table->softDeletes(); + $table->boolean('pruned')->default(false); + $table->timestamps(); + }); + }); + } + + public function testPrunableMethodMustBeImplemented() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'The prunable method must be implemented on the model class.', + ); + + PrunableTestModelMissingPrunableMethod::create()->pruneAll(); + } + + public function testPrunesRecords() + { + app('events') + ->shouldReceive('dispatch') + ->times(2) + ->with(m::type(ModelsPruned::class)); + + PrunableTestModel::insert(collect(range(1, 5000))->map(function ($id) { + return ['id' => $id]; + })->all()); + + $count = (new PrunableTestModel)->pruneAll(); + + $this->assertEquals(1500, $count); + $this->assertEquals(3500, PrunableTestModel::count()); + } + + public function testPrunesSoftDeletedRecords() + { + app('events') + ->shouldReceive('dispatch') + ->times(3) + ->with(m::type(ModelsPruned::class)); + + PrunableSoftDeleteTestModel::insert(collect(range(1, 5000))->map(function ($id) { + return ['id' => $id, 'deleted_at' => now()]; + })->all()); + + $count = (new PrunableSoftDeleteTestModel)->pruneAll(); + + $this->assertEquals(3000, $count); + $this->assertEquals(0, PrunableSoftDeleteTestModel::count()); + $this->assertEquals(2000, PrunableSoftDeleteTestModel::withTrashed()->count()); + } + + public function testPruneWithCustomPruneMethod() + { + app('events') + ->shouldReceive('dispatch') + ->times(1) + ->with(m::type(ModelsPruned::class)); + + PrunableWithCustomPruneMethodTestModel::insert(collect(range(1, 5000))->map(function ($id) { + return ['id' => $id]; + })->all()); + + $count = (new PrunableWithCustomPruneMethodTestModel)->pruneAll(); + + $this->assertEquals(1000, $count); + $this->assertTrue((bool) PrunableWithCustomPruneMethodTestModel::first()->pruned); + $this->assertFalse((bool) PrunableWithCustomPruneMethodTestModel::orderBy('id', 'desc')->first()->pruned); + $this->assertEquals(5000, PrunableWithCustomPruneMethodTestModel::count()); + } + + public function tearDown(): void + { + parent::tearDown(); + + Container::setInstance(null); + + m::close(); + } +} + +class PrunableTestModel extends Model +{ + use Prunable; + + public function prunable() + { + return $this->where('id', '<=', 1500); + } +} + +class PrunableSoftDeleteTestModel extends Model +{ + use Prunable, SoftDeletes; + + public function prunable() + { + return $this->where('id', '<=', 3000); + } +} + +class PrunableWithCustomPruneMethodTestModel extends Model +{ + use Prunable; + + public function prunable() + { + return $this->where('id', '<=', 1000); + } + + public function prune() + { + $this->forceFill([ + 'pruned' => true, + ])->save(); + } +} + +class PrunableTestModelMissingPrunableMethod extends Model +{ + use Prunable; +} From cc4219016f1cd5fe108d2b69e073ae51c14c1c47 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Fri, 2 Jul 2021 12:20:23 +0100 Subject: [PATCH 2/9] Apply fixes from StyleCI (#37887) --- src/Illuminate/Database/Console/PruneCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Database/Console/PruneCommand.php b/src/Illuminate/Database/Console/PruneCommand.php index a9bf96a823d8..028952cb7818 100644 --- a/src/Illuminate/Database/Console/PruneCommand.php +++ b/src/Illuminate/Database/Console/PruneCommand.php @@ -3,12 +3,12 @@ namespace Illuminate\Database\Console; use Illuminate\Console\Command; +use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Eloquent\MassPrunable; use Illuminate\Database\Eloquent\Prunable; use Illuminate\Database\Events\ModelsPruned; use Illuminate\Support\Str; use Symfony\Component\Finder\Finder; -use Illuminate\Contracts\Events\Dispatcher; class PruneCommand extends Command { From ddcdd8afbd0d693d651dea745d54907d54c6bbfb Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Fri, 2 Jul 2021 14:10:39 +0100 Subject: [PATCH 3/9] Addresses a few typos --- src/Illuminate/Database/Console/PruneCommand.php | 4 ++-- src/Illuminate/Database/Events/ModelsPruned.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Illuminate/Database/Console/PruneCommand.php b/src/Illuminate/Database/Console/PruneCommand.php index a9bf96a823d8..9cc4eba7ca6d 100644 --- a/src/Illuminate/Database/Console/PruneCommand.php +++ b/src/Illuminate/Database/Console/PruneCommand.php @@ -17,7 +17,7 @@ class PruneCommand extends Command * * @var string */ - protected $signature = 'db:prune {--model=* : Class names the of models to prune}'; + protected $signature = 'db:prune {--model=* : Class names of the models to be pruned}'; /** * The console command description. @@ -53,7 +53,7 @@ public function handle(Dispatcher $events) } /** - * Dertermine the models that should be pruned. + * Determine the models that should be pruned. * * @return array */ diff --git a/src/Illuminate/Database/Events/ModelsPruned.php b/src/Illuminate/Database/Events/ModelsPruned.php index 6a3f9d5d9df5..1b2a5869c5aa 100644 --- a/src/Illuminate/Database/Events/ModelsPruned.php +++ b/src/Illuminate/Database/Events/ModelsPruned.php @@ -5,14 +5,14 @@ class ModelsPruned { /** - * The model class pruned. + * The model that was pruned. * * @var string */ public $model; /** - * The amount of models records pruned. + * The amount of pruned records. * * @var int */ From 63ba15c66c6c666c0321a29b3364b41f834cc235 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Fri, 2 Jul 2021 16:22:13 +0100 Subject: [PATCH 4/9] Avoids hit SQLITE_MAX_VARIABLE_NUMBER in tests --- .../Database/EloquentMassPrunableTest.php | 12 ++++++++---- .../Database/EloquentPrunableTest.php | 18 ++++++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/tests/Integration/Database/EloquentMassPrunableTest.php b/tests/Integration/Database/EloquentMassPrunableTest.php index 7881cf4cb559..4f6f307b48c8 100644 --- a/tests/Integration/Database/EloquentMassPrunableTest.php +++ b/tests/Integration/Database/EloquentMassPrunableTest.php @@ -58,9 +58,11 @@ public function testPrunesRecords() ->times(2) ->with(m::type(ModelsPruned::class)); - MassPrunableTestModel::insert(collect(range(1, 5000))->map(function ($id) { + collect(range(1, 5000))->map(function ($id) { return ['id' => $id]; - })->all()); + })->chunk(500)->each(function ($chunk) { + MassPrunableTestModel::insert($chunk->all()); + }); $count = (new MassPrunableTestModel)->pruneAll(); @@ -75,9 +77,11 @@ public function testPrunesSoftDeletedRecords() ->times(3) ->with(m::type(ModelsPruned::class)); - MassPrunableSoftDeleteTestModel::insert(collect(range(1, 5000))->map(function ($id) { + collect(range(1, 5000))->map(function ($id) { return ['id' => $id, 'deleted_at' => now()]; - })->all()); + })->chunk(500)->each(function ($chunk) { + MassPrunableSoftDeleteTestModel::insert($chunk->all()); + }); $count = (new MassPrunableSoftDeleteTestModel)->pruneAll(); diff --git a/tests/Integration/Database/EloquentPrunableTest.php b/tests/Integration/Database/EloquentPrunableTest.php index f9c60706681f..c0724ea5ca29 100644 --- a/tests/Integration/Database/EloquentPrunableTest.php +++ b/tests/Integration/Database/EloquentPrunableTest.php @@ -59,9 +59,11 @@ public function testPrunesRecords() ->times(2) ->with(m::type(ModelsPruned::class)); - PrunableTestModel::insert(collect(range(1, 5000))->map(function ($id) { + collect(range(1, 5000))->map(function ($id) { return ['id' => $id]; - })->all()); + })->chunk(500)->each(function ($chunk) { + PrunableTestModel::insert($chunk->all()); + }); $count = (new PrunableTestModel)->pruneAll(); @@ -76,9 +78,11 @@ public function testPrunesSoftDeletedRecords() ->times(3) ->with(m::type(ModelsPruned::class)); - PrunableSoftDeleteTestModel::insert(collect(range(1, 5000))->map(function ($id) { + collect(range(1, 5000))->map(function ($id) { return ['id' => $id, 'deleted_at' => now()]; - })->all()); + })->chunk(500)->each(function ($chunk) { + PrunableSoftDeleteTestModel::insert($chunk->all()); + }); $count = (new PrunableSoftDeleteTestModel)->pruneAll(); @@ -94,9 +98,11 @@ public function testPruneWithCustomPruneMethod() ->times(1) ->with(m::type(ModelsPruned::class)); - PrunableWithCustomPruneMethodTestModel::insert(collect(range(1, 5000))->map(function ($id) { + collect(range(1, 5000))->map(function ($id) { return ['id' => $id]; - })->all()); + })->chunk(500)->each(function ($chunk) { + PrunableWithCustomPruneMethodTestModel::insert($chunk->all()); + }); $count = (new PrunableWithCustomPruneMethodTestModel)->pruneAll(); From c3af0dcb9acea2b9c2b07f7c952d4c59c098279e Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Fri, 2 Jul 2021 16:27:32 +0100 Subject: [PATCH 5/9] Avoids hit SQLITE_MAX_VARIABLE_NUMBER in tests --- tests/Integration/Database/EloquentMassPrunableTest.php | 4 ++-- tests/Integration/Database/EloquentPrunableTest.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Integration/Database/EloquentMassPrunableTest.php b/tests/Integration/Database/EloquentMassPrunableTest.php index 4f6f307b48c8..751f72f44479 100644 --- a/tests/Integration/Database/EloquentMassPrunableTest.php +++ b/tests/Integration/Database/EloquentMassPrunableTest.php @@ -60,7 +60,7 @@ public function testPrunesRecords() collect(range(1, 5000))->map(function ($id) { return ['id' => $id]; - })->chunk(500)->each(function ($chunk) { + })->chunk(200)->each(function ($chunk) { MassPrunableTestModel::insert($chunk->all()); }); @@ -79,7 +79,7 @@ public function testPrunesSoftDeletedRecords() collect(range(1, 5000))->map(function ($id) { return ['id' => $id, 'deleted_at' => now()]; - })->chunk(500)->each(function ($chunk) { + })->chunk(200)->each(function ($chunk) { MassPrunableSoftDeleteTestModel::insert($chunk->all()); }); diff --git a/tests/Integration/Database/EloquentPrunableTest.php b/tests/Integration/Database/EloquentPrunableTest.php index c0724ea5ca29..f78174a00a8a 100644 --- a/tests/Integration/Database/EloquentPrunableTest.php +++ b/tests/Integration/Database/EloquentPrunableTest.php @@ -61,7 +61,7 @@ public function testPrunesRecords() collect(range(1, 5000))->map(function ($id) { return ['id' => $id]; - })->chunk(500)->each(function ($chunk) { + })->chunk(200)->each(function ($chunk) { PrunableTestModel::insert($chunk->all()); }); @@ -80,7 +80,7 @@ public function testPrunesSoftDeletedRecords() collect(range(1, 5000))->map(function ($id) { return ['id' => $id, 'deleted_at' => now()]; - })->chunk(500)->each(function ($chunk) { + })->chunk(200)->each(function ($chunk) { PrunableSoftDeleteTestModel::insert($chunk->all()); }); @@ -100,7 +100,7 @@ public function testPruneWithCustomPruneMethod() collect(range(1, 5000))->map(function ($id) { return ['id' => $id]; - })->chunk(500)->each(function ($chunk) { + })->chunk(200)->each(function ($chunk) { PrunableWithCustomPruneMethodTestModel::insert($chunk->all()); }); From ee3e144673edf44b264072d028e9713d8ac89834 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Fri, 2 Jul 2021 16:33:30 +0100 Subject: [PATCH 6/9] Fixes windows tests regarding the output of commands --- tests/Database/PruneCommandTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Database/PruneCommandTest.php b/tests/Database/PruneCommandTest.php index 5bc14742c16f..2b98680c0da4 100644 --- a/tests/Database/PruneCommandTest.php +++ b/tests/Database/PruneCommandTest.php @@ -37,7 +37,7 @@ public function testPrunableModelWithPrunableRecords() 10 [Illuminate\Tests\Database\PrunableTestModelWithPrunableRecords] records have been pruned. 20 [Illuminate\Tests\Database\PrunableTestModelWithPrunableRecords] records have been pruned. -EOF, $output->fetch()); +EOF, str_replace("\r", '', $output->fetch())); } public function testPrunableTestModelWithoutPrunableRecords() @@ -47,7 +47,7 @@ public function testPrunableTestModelWithoutPrunableRecords() $this->assertEquals(<<<'EOF' No prunable [Illuminate\Tests\Database\PrunableTestModelWithoutPrunableRecords] records found. -EOF, $output->fetch()); +EOF, str_replace("\r", '', $output->fetch())); } public function testNonPrunableTest() @@ -57,7 +57,7 @@ public function testNonPrunableTest() $this->assertEquals(<<<'EOF' No prunable [Illuminate\Tests\Database\NonPrunableTestModel] records found. -EOF, $output->fetch()); +EOF, str_replace("\r", '', $output->fetch())); } protected function artisan($arguments) From c7bc71056856e5a603025cc1df78fc09a01f2f2e Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Tue, 6 Jul 2021 14:38:11 -0500 Subject: [PATCH 7/9] formatting --- .../Database/Console/PruneCommand.php | 9 +++------ .../Eloquent/Concerns/PrunableQueries.php | 18 ------------------ .../Database/Eloquent/MassPrunable.php | 13 +++++++++++-- src/Illuminate/Database/Eloquent/Prunable.php | 15 ++++++++++++--- .../Database/EloquentMassPrunableTest.php | 2 +- .../Database/EloquentPrunableTest.php | 2 +- 6 files changed, 28 insertions(+), 31 deletions(-) delete mode 100644 src/Illuminate/Database/Eloquent/Concerns/PrunableQueries.php diff --git a/src/Illuminate/Database/Console/PruneCommand.php b/src/Illuminate/Database/Console/PruneCommand.php index f15c5e1f1db3..e4abbd0abbb5 100644 --- a/src/Illuminate/Database/Console/PruneCommand.php +++ b/src/Illuminate/Database/Console/PruneCommand.php @@ -17,7 +17,7 @@ class PruneCommand extends Command * * @var string */ - protected $signature = 'db:prune {--model=* : Class names of the models to be pruned}'; + protected $signature = 'model:prune {--model=* : Class names of the models to be pruned}'; /** * The console command description. @@ -35,10 +35,7 @@ class PruneCommand extends Command public function handle(Dispatcher $events) { $events->listen(ModelsPruned::class, function ($event) { - $amount = $event->amount; - $model = $event->model; - - $this->info("$amount [$model] records have been pruned."); + $this->info("{$event->amount} [{$event->model}] records have been pruned."); }); $this->models()->each(function ($model) { @@ -78,7 +75,7 @@ protected function models() } /** - * Checks if the given model class is prunable. + * Determine if the given model class is prunable. * * @param string $model * @return bool diff --git a/src/Illuminate/Database/Eloquent/Concerns/PrunableQueries.php b/src/Illuminate/Database/Eloquent/Concerns/PrunableQueries.php deleted file mode 100644 index ba8e23825acd..000000000000 --- a/src/Illuminate/Database/Eloquent/Concerns/PrunableQueries.php +++ /dev/null @@ -1,18 +0,0 @@ -forceDelete() : $query->delete(); diff --git a/src/Illuminate/Database/Eloquent/Prunable.php b/src/Illuminate/Database/Eloquent/Prunable.php index 00c50f778813..7e0a8ba5030c 100644 --- a/src/Illuminate/Database/Eloquent/Prunable.php +++ b/src/Illuminate/Database/Eloquent/Prunable.php @@ -3,10 +3,19 @@ namespace Illuminate\Database\Eloquent; use Illuminate\Database\Events\ModelsPruned; +use LogicException; trait Prunable { - use Concerns\PrunableQueries; + /** + * Get the prunable model query. + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function prunable() + { + throw new LogicException('Please implement the prunable method on your model.'); + } /** * Prune all prunable models in the database. @@ -18,7 +27,7 @@ public function pruneAll() $total = 0; $this->prunable() - ->when(in_array(SoftDeletes::class, class_uses_recursive(get_called_class())), function ($query) { + ->when(in_array(SoftDeletes::class, class_uses_recursive(get_class($this))), function ($query) { $query->withTrashed(); })->chunkById(1000, function ($models) use (&$total) { $models->each->prune(); @@ -37,7 +46,7 @@ public function pruneAll() */ public function prune() { - return in_array(SoftDeletes::class, class_uses_recursive(get_called_class())) + return in_array(SoftDeletes::class, class_uses_recursive(get_class($this))) ? $this->forceDelete() : $this->delete(); } diff --git a/tests/Integration/Database/EloquentMassPrunableTest.php b/tests/Integration/Database/EloquentMassPrunableTest.php index 751f72f44479..7517e5ad105c 100644 --- a/tests/Integration/Database/EloquentMassPrunableTest.php +++ b/tests/Integration/Database/EloquentMassPrunableTest.php @@ -45,7 +45,7 @@ public function testPrunableMethodMustBeImplemented() { $this->expectException(LogicException::class); $this->expectExceptionMessage( - 'The prunable method must be implemented on the model class.', + 'Please implement', ); MassPrunableTestModelMissingPrunableMethod::create()->pruneAll(); diff --git a/tests/Integration/Database/EloquentPrunableTest.php b/tests/Integration/Database/EloquentPrunableTest.php index f78174a00a8a..fb8d6c3ec41e 100644 --- a/tests/Integration/Database/EloquentPrunableTest.php +++ b/tests/Integration/Database/EloquentPrunableTest.php @@ -46,7 +46,7 @@ public function testPrunableMethodMustBeImplemented() { $this->expectException(LogicException::class); $this->expectExceptionMessage( - 'The prunable method must be implemented on the model class.', + 'Please implement', ); PrunableTestModelMissingPrunableMethod::create()->pruneAll(); From 1dd702b187bc4804e1b4bbefa85eefbfd3e4a9fb Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Tue, 6 Jul 2021 15:01:30 -0500 Subject: [PATCH 8/9] add configurable chunk size to prunable --- .../Database/Console/PruneCommand.php | 18 +++++++--- .../Database/Eloquent/MassPrunable.php | 33 ++++++++++--------- src/Illuminate/Database/Eloquent/Prunable.php | 26 ++++++++------- .../Database/Events/ModelsPruned.php | 12 +++---- 4 files changed, 51 insertions(+), 38 deletions(-) diff --git a/src/Illuminate/Database/Console/PruneCommand.php b/src/Illuminate/Database/Console/PruneCommand.php index e4abbd0abbb5..57d7e1186de0 100644 --- a/src/Illuminate/Database/Console/PruneCommand.php +++ b/src/Illuminate/Database/Console/PruneCommand.php @@ -17,14 +17,16 @@ class PruneCommand extends Command * * @var string */ - protected $signature = 'model:prune {--model=* : Class names of the models to be pruned}'; + protected $signature = 'model:prune + {--model=* : Class names of the models to be pruned} + {--chunk=1000 : The number of models to retrieve per chunk of models to be deleted}'; /** * The console command description. * * @var string */ - protected $description = 'Prune obsolete models'; + protected $description = 'Prune models that are no longer needed'; /** * Execute the console command. @@ -35,11 +37,19 @@ class PruneCommand extends Command public function handle(Dispatcher $events) { $events->listen(ModelsPruned::class, function ($event) { - $this->info("{$event->amount} [{$event->model}] records have been pruned."); + $this->info("{$event->count} [{$event->model}] records have been pruned."); }); $this->models()->each(function ($model) { - $total = $this->isPrunable($model) ? (new $model)->pruneAll() : 0; + $instance = new $model; + + $chunkSize = property_exists($instance, 'prunableChunkSize') + ? $instance->prunableChunkSize + : $this->option('chunk'); + + $total = $this->isPrunable($model) + ? $instance->pruneAll($chunkSize) + : 0; if ($total == 0) { $this->info("No prunable [$model] records found."); diff --git a/src/Illuminate/Database/Eloquent/MassPrunable.php b/src/Illuminate/Database/Eloquent/MassPrunable.php index 0380095ffdba..254ca9bd29f0 100644 --- a/src/Illuminate/Database/Eloquent/MassPrunable.php +++ b/src/Illuminate/Database/Eloquent/MassPrunable.php @@ -7,26 +7,17 @@ trait MassPrunable { - /** - * Get the prunable model query. - * - * @return \Illuminate\Database\Eloquent\Builder - */ - public function prunable() - { - throw new LogicException('Please implement the prunable method on your model.'); - } - /** * Prune all prunable models in the database. * + * @param int $chunkSize * @return int */ - public function pruneAll() + public function pruneAll(int $chunkSize = 1000) { - $query = tap($this->prunable(), function ($query) { - $query->when(! $query->getQuery()->limit, function ($query) { - $query->limit(1000); + $query = tap($this->prunable(), function ($query) use ($chunkSize) { + $query->when(! $query->getQuery()->limit, function ($query) use ($chunkSize) { + $query->limit($chunkSize); }); }); @@ -34,8 +25,8 @@ public function pruneAll() do { $total += $count = in_array(SoftDeletes::class, class_uses_recursive(get_class($this))) - ? $query->forceDelete() - : $query->delete(); + ? $query->forceDelete() + : $query->delete(); if ($count > 0) { event(new ModelsPruned(static::class, $total)); @@ -44,4 +35,14 @@ public function pruneAll() return $total; } + + /** + * Get the prunable model query. + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function prunable() + { + throw new LogicException('Please implement the prunable method on your model.'); + } } diff --git a/src/Illuminate/Database/Eloquent/Prunable.php b/src/Illuminate/Database/Eloquent/Prunable.php index 7e0a8ba5030c..c26e246be2ea 100644 --- a/src/Illuminate/Database/Eloquent/Prunable.php +++ b/src/Illuminate/Database/Eloquent/Prunable.php @@ -7,30 +7,22 @@ trait Prunable { - /** - * Get the prunable model query. - * - * @return \Illuminate\Database\Eloquent\Builder - */ - public function prunable() - { - throw new LogicException('Please implement the prunable method on your model.'); - } - /** * Prune all prunable models in the database. * + * @param int $chunkSize * @return int */ - public function pruneAll() + public function pruneAll(int $chunkSize = 1000) { $total = 0; $this->prunable() ->when(in_array(SoftDeletes::class, class_uses_recursive(get_class($this))), function ($query) { $query->withTrashed(); - })->chunkById(1000, function ($models) use (&$total) { + })->chunkById($chunkSize, function ($models) use (&$total) { $models->each->prune(); + $total += $models->count(); event(new ModelsPruned(static::class, $total)); @@ -39,6 +31,16 @@ public function pruneAll() return $total; } + /** + * Get the prunable model query. + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function prunable() + { + throw new LogicException('Please implement the prunable method on your model.'); + } + /** * Prune the model in the database. * diff --git a/src/Illuminate/Database/Events/ModelsPruned.php b/src/Illuminate/Database/Events/ModelsPruned.php index 1b2a5869c5aa..ca8bee9e0f5d 100644 --- a/src/Illuminate/Database/Events/ModelsPruned.php +++ b/src/Illuminate/Database/Events/ModelsPruned.php @@ -5,29 +5,29 @@ class ModelsPruned { /** - * The model that was pruned. + * The class name of the model that was pruned. * * @var string */ public $model; /** - * The amount of pruned records. + * The number of pruned records. * * @var int */ - public $amount; + public $count; /** * Create a new event instance. * * @param string $model - * @param int $amount + * @param int $count * @return void */ - public function __construct($model, $amount) + public function __construct($model, $count) { $this->model = $model; - $this->amount = $amount; + $this->count = $count; } } From ebadc43cfb9e43bca77a4225edef697c16d5c42f Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Tue, 6 Jul 2021 15:05:31 -0500 Subject: [PATCH 9/9] add before prune hook to avoid need to call parent --- src/Illuminate/Database/Eloquent/Prunable.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Illuminate/Database/Eloquent/Prunable.php b/src/Illuminate/Database/Eloquent/Prunable.php index c26e246be2ea..b4ce1b03403a 100644 --- a/src/Illuminate/Database/Eloquent/Prunable.php +++ b/src/Illuminate/Database/Eloquent/Prunable.php @@ -48,8 +48,20 @@ public function prunable() */ public function prune() { + $this->pruning(); + return in_array(SoftDeletes::class, class_uses_recursive(get_class($this))) ? $this->forceDelete() : $this->delete(); } + + /** + * Prepare the model for pruning. + * + * @return void + */ + protected function pruning() + { + // + } }