Skip to content
99 changes: 99 additions & 0 deletions src/Illuminate/Database/Console/PruneCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

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;

class PruneCommand extends Command
{
/**
* The console command name.
*
* @var string
*/
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 models that are no longer needed';

/**
* Execute the console command.
*
* @param \Illuminate\Contracts\Events\Dispatcher $events
* @return void
*/
public function handle(Dispatcher $events)
{
$events->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);
}
}
48 changes: 48 additions & 0 deletions src/Illuminate/Database/Eloquent/MassPrunable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace Illuminate\Database\Eloquent;

use Illuminate\Database\Events\ModelsPruned;
use LogicException;

trait MassPrunable
{
/**
* Prune all prunable models in the database.
*
* @param int $chunkSize
* @return int
*/
public function pruneAll(int $chunkSize = 1000)
{
$query = tap($this->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.');
}
}
67 changes: 67 additions & 0 deletions src/Illuminate/Database/Eloquent/Prunable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

namespace Illuminate\Database\Eloquent;

use Illuminate\Database\Events\ModelsPruned;
use LogicException;

trait Prunable
{
/**
* Prune all prunable models in the database.
*
* @param int $chunkSize
* @return int
*/
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($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()
{
//
}
}
33 changes: 33 additions & 0 deletions src/Illuminate/Database/Events/ModelsPruned.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace Illuminate\Database\Events;

class ModelsPruned
{
/**
* The class name of the model that was pruned.
*
* @var string
*/
public $model;

/**
* The number of pruned records.
*
* @var int
*/
public $count;

/**
* Create a new event instance.
*
* @param string $model
* @param int $count
* @return void
*/
public function __construct($model, $count)
{
$this->model = $model;
$this->count = $count;
}
}
14 changes: 14 additions & 0 deletions src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.
*
Expand Down
109 changes: 109 additions & 0 deletions tests/Database/PruneCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

namespace Illuminate\Tests\Database;

use Illuminate\Container\Container;
use Illuminate\Contracts\Events\Dispatcher as DispatcherContract;
use Illuminate\Database\Console\PruneCommand;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Prunable;
use Illuminate\Database\Events\ModelsPruned;
use Illuminate\Events\Dispatcher;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;

class PruneCommandTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();

Container::setInstance($container = new Container);

$container->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
{
// ..
}
Loading