Skip to content

Commit 437cbef

Browse files
[8.x] Adds the possibility of having "Prunable" models (#37889)
* Adds possibility of having "Prunable" models * Apply fixes from StyleCI (#37887) * Addresses a few typos * Avoids hit SQLITE_MAX_VARIABLE_NUMBER in tests * Avoids hit SQLITE_MAX_VARIABLE_NUMBER in tests * Fixes windows tests regarding the output of commands * formatting * add configurable chunk size to prunable * add before prune hook to avoid need to call parent Co-authored-by: Taylor Otwell <[email protected]>
1 parent 95413a5 commit 437cbef

File tree

8 files changed

+661
-0
lines changed

8 files changed

+661
-0
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
namespace Illuminate\Database\Console;
4+
5+
use Illuminate\Console\Command;
6+
use Illuminate\Contracts\Events\Dispatcher;
7+
use Illuminate\Database\Eloquent\MassPrunable;
8+
use Illuminate\Database\Eloquent\Prunable;
9+
use Illuminate\Database\Events\ModelsPruned;
10+
use Illuminate\Support\Str;
11+
use Symfony\Component\Finder\Finder;
12+
13+
class PruneCommand extends Command
14+
{
15+
/**
16+
* The console command name.
17+
*
18+
* @var string
19+
*/
20+
protected $signature = 'model:prune
21+
{--model=* : Class names of the models to be pruned}
22+
{--chunk=1000 : The number of models to retrieve per chunk of models to be deleted}';
23+
24+
/**
25+
* The console command description.
26+
*
27+
* @var string
28+
*/
29+
protected $description = 'Prune models that are no longer needed';
30+
31+
/**
32+
* Execute the console command.
33+
*
34+
* @param \Illuminate\Contracts\Events\Dispatcher $events
35+
* @return void
36+
*/
37+
public function handle(Dispatcher $events)
38+
{
39+
$events->listen(ModelsPruned::class, function ($event) {
40+
$this->info("{$event->count} [{$event->model}] records have been pruned.");
41+
});
42+
43+
$this->models()->each(function ($model) {
44+
$instance = new $model;
45+
46+
$chunkSize = property_exists($instance, 'prunableChunkSize')
47+
? $instance->prunableChunkSize
48+
: $this->option('chunk');
49+
50+
$total = $this->isPrunable($model)
51+
? $instance->pruneAll($chunkSize)
52+
: 0;
53+
54+
if ($total == 0) {
55+
$this->info("No prunable [$model] records found.");
56+
}
57+
});
58+
59+
$events->forget(ModelsPruned::class);
60+
}
61+
62+
/**
63+
* Determine the models that should be pruned.
64+
*
65+
* @return array
66+
*/
67+
protected function models()
68+
{
69+
if (! empty($models = $this->option('model'))) {
70+
return collect($models);
71+
}
72+
73+
return collect((new Finder)->in(app_path('Models'))->files())
74+
->map(function ($model) {
75+
$namespace = $this->laravel->getNamespace();
76+
77+
return $namespace.str_replace(
78+
['/', '.php'],
79+
['\\', ''],
80+
Str::after($model->getRealPath(), realpath(app_path()).DIRECTORY_SEPARATOR)
81+
);
82+
})->filter(function ($model) {
83+
return $this->isPrunable($model);
84+
})->values();
85+
}
86+
87+
/**
88+
* Determine if the given model class is prunable.
89+
*
90+
* @param string $model
91+
* @return bool
92+
*/
93+
protected function isPrunable($model)
94+
{
95+
$uses = class_uses_recursive($model);
96+
97+
return in_array(Prunable::class, $uses) || in_array(MassPrunable::class, $uses);
98+
}
99+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace Illuminate\Database\Eloquent;
4+
5+
use Illuminate\Database\Events\ModelsPruned;
6+
use LogicException;
7+
8+
trait MassPrunable
9+
{
10+
/**
11+
* Prune all prunable models in the database.
12+
*
13+
* @param int $chunkSize
14+
* @return int
15+
*/
16+
public function pruneAll(int $chunkSize = 1000)
17+
{
18+
$query = tap($this->prunable(), function ($query) use ($chunkSize) {
19+
$query->when(! $query->getQuery()->limit, function ($query) use ($chunkSize) {
20+
$query->limit($chunkSize);
21+
});
22+
});
23+
24+
$total = 0;
25+
26+
do {
27+
$total += $count = in_array(SoftDeletes::class, class_uses_recursive(get_class($this)))
28+
? $query->forceDelete()
29+
: $query->delete();
30+
31+
if ($count > 0) {
32+
event(new ModelsPruned(static::class, $total));
33+
}
34+
} while ($count > 0);
35+
36+
return $total;
37+
}
38+
39+
/**
40+
* Get the prunable model query.
41+
*
42+
* @return \Illuminate\Database\Eloquent\Builder
43+
*/
44+
public function prunable()
45+
{
46+
throw new LogicException('Please implement the prunable method on your model.');
47+
}
48+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
namespace Illuminate\Database\Eloquent;
4+
5+
use Illuminate\Database\Events\ModelsPruned;
6+
use LogicException;
7+
8+
trait Prunable
9+
{
10+
/**
11+
* Prune all prunable models in the database.
12+
*
13+
* @param int $chunkSize
14+
* @return int
15+
*/
16+
public function pruneAll(int $chunkSize = 1000)
17+
{
18+
$total = 0;
19+
20+
$this->prunable()
21+
->when(in_array(SoftDeletes::class, class_uses_recursive(get_class($this))), function ($query) {
22+
$query->withTrashed();
23+
})->chunkById($chunkSize, function ($models) use (&$total) {
24+
$models->each->prune();
25+
26+
$total += $models->count();
27+
28+
event(new ModelsPruned(static::class, $total));
29+
});
30+
31+
return $total;
32+
}
33+
34+
/**
35+
* Get the prunable model query.
36+
*
37+
* @return \Illuminate\Database\Eloquent\Builder
38+
*/
39+
public function prunable()
40+
{
41+
throw new LogicException('Please implement the prunable method on your model.');
42+
}
43+
44+
/**
45+
* Prune the model in the database.
46+
*
47+
* @return bool|null
48+
*/
49+
public function prune()
50+
{
51+
$this->pruning();
52+
53+
return in_array(SoftDeletes::class, class_uses_recursive(get_class($this)))
54+
? $this->forceDelete()
55+
: $this->delete();
56+
}
57+
58+
/**
59+
* Prepare the model for pruning.
60+
*
61+
* @return void
62+
*/
63+
protected function pruning()
64+
{
65+
//
66+
}
67+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace Illuminate\Database\Events;
4+
5+
class ModelsPruned
6+
{
7+
/**
8+
* The class name of the model that was pruned.
9+
*
10+
* @var string
11+
*/
12+
public $model;
13+
14+
/**
15+
* The number of pruned records.
16+
*
17+
* @var int
18+
*/
19+
public $count;
20+
21+
/**
22+
* Create a new event instance.
23+
*
24+
* @param string $model
25+
* @param int $count
26+
* @return void
27+
*/
28+
public function __construct($model, $count)
29+
{
30+
$this->model = $model;
31+
$this->count = $count;
32+
}
33+
}

src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Illuminate\Database\Console\DbCommand;
1616
use Illuminate\Database\Console\DumpCommand;
1717
use Illuminate\Database\Console\Factories\FactoryMakeCommand;
18+
use Illuminate\Database\Console\PruneCommand;
1819
use Illuminate\Database\Console\Seeds\SeedCommand;
1920
use Illuminate\Database\Console\Seeds\SeederMakeCommand;
2021
use Illuminate\Database\Console\WipeCommand;
@@ -94,6 +95,7 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid
9495
'ConfigCache' => 'command.config.cache',
9596
'ConfigClear' => 'command.config.clear',
9697
'Db' => DbCommand::class,
98+
'DbPrune' => 'command.db.prune',
9799
'DbWipe' => 'command.db.wipe',
98100
'Down' => 'command.down',
99101
'Environment' => 'command.environment',
@@ -352,6 +354,18 @@ protected function registerDbCommand()
352354
$this->app->singleton(DbCommand::class);
353355
}
354356

357+
/**
358+
* Register the command.
359+
*
360+
* @return void
361+
*/
362+
protected function registerDbPruneCommand()
363+
{
364+
$this->app->singleton('command.db.prune', function ($app) {
365+
return new PruneCommand($app['events']);
366+
});
367+
}
368+
355369
/**
356370
* Register the command.
357371
*

tests/Database/PruneCommandTest.php

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
namespace Illuminate\Tests\Database;
4+
5+
use Illuminate\Container\Container;
6+
use Illuminate\Contracts\Events\Dispatcher as DispatcherContract;
7+
use Illuminate\Database\Console\PruneCommand;
8+
use Illuminate\Database\Eloquent\MassPrunable;
9+
use Illuminate\Database\Eloquent\Model;
10+
use Illuminate\Database\Eloquent\Prunable;
11+
use Illuminate\Database\Events\ModelsPruned;
12+
use Illuminate\Events\Dispatcher;
13+
use PHPUnit\Framework\TestCase;
14+
use Symfony\Component\Console\Input\ArrayInput;
15+
use Symfony\Component\Console\Output\BufferedOutput;
16+
17+
class PruneCommandTest extends TestCase
18+
{
19+
protected function setUp(): void
20+
{
21+
parent::setUp();
22+
23+
Container::setInstance($container = new Container);
24+
25+
$container->singleton(DispatcherContract::class, function () {
26+
return new Dispatcher();
27+
});
28+
29+
$container->alias(DispatcherContract::class, 'events');
30+
}
31+
32+
public function testPrunableModelWithPrunableRecords()
33+
{
34+
$output = $this->artisan(['--model' => PrunableTestModelWithPrunableRecords::class]);
35+
36+
$this->assertEquals(<<<'EOF'
37+
10 [Illuminate\Tests\Database\PrunableTestModelWithPrunableRecords] records have been pruned.
38+
20 [Illuminate\Tests\Database\PrunableTestModelWithPrunableRecords] records have been pruned.
39+
40+
EOF, str_replace("\r", '', $output->fetch()));
41+
}
42+
43+
public function testPrunableTestModelWithoutPrunableRecords()
44+
{
45+
$output = $this->artisan(['--model' => PrunableTestModelWithoutPrunableRecords::class]);
46+
47+
$this->assertEquals(<<<'EOF'
48+
No prunable [Illuminate\Tests\Database\PrunableTestModelWithoutPrunableRecords] records found.
49+
50+
EOF, str_replace("\r", '', $output->fetch()));
51+
}
52+
53+
public function testNonPrunableTest()
54+
{
55+
$output = $this->artisan(['--model' => NonPrunableTestModel::class]);
56+
57+
$this->assertEquals(<<<'EOF'
58+
No prunable [Illuminate\Tests\Database\NonPrunableTestModel] records found.
59+
60+
EOF, str_replace("\r", '', $output->fetch()));
61+
}
62+
63+
protected function artisan($arguments)
64+
{
65+
$input = new ArrayInput($arguments);
66+
$output = new BufferedOutput;
67+
68+
tap(new PruneCommand())
69+
->setLaravel(Container::getInstance())
70+
->run($input, $output);
71+
72+
return $output;
73+
}
74+
75+
public function tearDown(): void
76+
{
77+
parent::tearDown();
78+
79+
Container::setInstance(null);
80+
}
81+
}
82+
83+
class PrunableTestModelWithPrunableRecords extends Model
84+
{
85+
use MassPrunable;
86+
87+
public function pruneAll()
88+
{
89+
event(new ModelsPruned(static::class, 10));
90+
event(new ModelsPruned(static::class, 20));
91+
92+
return 20;
93+
}
94+
}
95+
96+
class PrunableTestModelWithoutPrunableRecords extends Model
97+
{
98+
use Prunable;
99+
100+
public function pruneAll()
101+
{
102+
return 0;
103+
}
104+
}
105+
106+
class NonPrunableTestModel extends Model
107+
{
108+
// ..
109+
}

0 commit comments

Comments
 (0)