diff --git a/src/Illuminate/Database/Console/PruneCommand.php b/src/Illuminate/Database/Console/PruneCommand.php new file mode 100644 index 000000000000..57d7e1186de0 --- /dev/null +++ b/src/Illuminate/Database/Console/PruneCommand.php @@ -0,0 +1,99 @@ +listen(ModelsPruned::class, function ($event) { + $this->info("{$event->count} [{$event->model}] records have been pruned."); + }); + + $this->models()->each(function ($model) { + $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."); + } + }); + + $events->forget(ModelsPruned::class); + } + + /** + * Determine 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(); + } + + /** + * Determine 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/MassPrunable.php b/src/Illuminate/Database/Eloquent/MassPrunable.php new file mode 100644 index 000000000000..254ca9bd29f0 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/MassPrunable.php @@ -0,0 +1,48 @@ +prunable(), function ($query) use ($chunkSize) { + $query->when(! $query->getQuery()->limit, function ($query) use ($chunkSize) { + $query->limit($chunkSize); + }); + }); + + $total = 0; + + do { + $total += $count = in_array(SoftDeletes::class, class_uses_recursive(get_class($this))) + ? $query->forceDelete() + : $query->delete(); + + if ($count > 0) { + event(new ModelsPruned(static::class, $total)); + } + } while ($count > 0); + + 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 new file mode 100644 index 000000000000..b4ce1b03403a --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Prunable.php @@ -0,0 +1,67 @@ +prunable() + ->when(in_array(SoftDeletes::class, class_uses_recursive(get_class($this))), function ($query) { + $query->withTrashed(); + })->chunkById($chunkSize, function ($models) use (&$total) { + $models->each->prune(); + + $total += $models->count(); + + event(new ModelsPruned(static::class, $total)); + }); + + 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. + * + * @return bool|null + */ + 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() + { + // + } +} diff --git a/src/Illuminate/Database/Events/ModelsPruned.php b/src/Illuminate/Database/Events/ModelsPruned.php new file mode 100644 index 000000000000..ca8bee9e0f5d --- /dev/null +++ b/src/Illuminate/Database/Events/ModelsPruned.php @@ -0,0 +1,33 @@ +model = $model; + $this->count = $count; + } +} 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..2b98680c0da4 --- /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, str_replace("\r", '', $output->fetch())); + } + + public function testPrunableTestModelWithoutPrunableRecords() + { + $output = $this->artisan(['--model' => PrunableTestModelWithoutPrunableRecords::class]); + + $this->assertEquals(<<<'EOF' +No prunable [Illuminate\Tests\Database\PrunableTestModelWithoutPrunableRecords] records found. + +EOF, str_replace("\r", '', $output->fetch())); + } + + public function testNonPrunableTest() + { + $output = $this->artisan(['--model' => NonPrunableTestModel::class]); + + $this->assertEquals(<<<'EOF' +No prunable [Illuminate\Tests\Database\NonPrunableTestModel] records found. + +EOF, str_replace("\r", '', $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..7517e5ad105c --- /dev/null +++ b/tests/Integration/Database/EloquentMassPrunableTest.php @@ -0,0 +1,126 @@ +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( + 'Please implement', + ); + + MassPrunableTestModelMissingPrunableMethod::create()->pruneAll(); + } + + public function testPrunesRecords() + { + app('events') + ->shouldReceive('dispatch') + ->times(2) + ->with(m::type(ModelsPruned::class)); + + collect(range(1, 5000))->map(function ($id) { + return ['id' => $id]; + })->chunk(200)->each(function ($chunk) { + MassPrunableTestModel::insert($chunk->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)); + + collect(range(1, 5000))->map(function ($id) { + return ['id' => $id, 'deleted_at' => now()]; + })->chunk(200)->each(function ($chunk) { + MassPrunableSoftDeleteTestModel::insert($chunk->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..fb8d6c3ec41e --- /dev/null +++ b/tests/Integration/Database/EloquentPrunableTest.php @@ -0,0 +1,165 @@ +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( + 'Please implement', + ); + + PrunableTestModelMissingPrunableMethod::create()->pruneAll(); + } + + public function testPrunesRecords() + { + app('events') + ->shouldReceive('dispatch') + ->times(2) + ->with(m::type(ModelsPruned::class)); + + collect(range(1, 5000))->map(function ($id) { + return ['id' => $id]; + })->chunk(200)->each(function ($chunk) { + PrunableTestModel::insert($chunk->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)); + + collect(range(1, 5000))->map(function ($id) { + return ['id' => $id, 'deleted_at' => now()]; + })->chunk(200)->each(function ($chunk) { + PrunableSoftDeleteTestModel::insert($chunk->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)); + + collect(range(1, 5000))->map(function ($id) { + return ['id' => $id]; + })->chunk(200)->each(function ($chunk) { + PrunableWithCustomPruneMethodTestModel::insert($chunk->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; +}