Skip to content

Commit 5a462dd

Browse files
committed
feat: add command to clean up orphaned custom field values
Adds `custom-fields:cleanup-orphaned-values` to remove custom field values whose parent entity was hard-deleted before the deleting hook existed. Supports --dry-run and --force flags.
1 parent a4886d1 commit 5a462dd

3 files changed

Lines changed: 272 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Relaticle\CustomFields\Console\Commands;
6+
7+
use Illuminate\Console\Command;
8+
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Relations\Relation;
10+
use Illuminate\Support\Facades\DB;
11+
use Relaticle\CustomFields\Models\CustomFieldValue;
12+
13+
final class CleanupOrphanedValuesCommand extends Command
14+
{
15+
/** @var string */
16+
protected $signature = 'custom-fields:cleanup-orphaned-values
17+
{--dry-run : Show what would be deleted without making changes}
18+
{--force : Run without confirmation prompts}';
19+
20+
/** @var string */
21+
protected $description = 'Remove custom field values whose parent entity no longer exists';
22+
23+
public function handle(): int
24+
{
25+
$isDryRun = (bool) $this->option('dry-run');
26+
$isForced = (bool) $this->option('force');
27+
28+
if ($isDryRun) {
29+
$this->warn('Running in DRY RUN mode - no changes will be made');
30+
$this->newLine();
31+
}
32+
33+
$table = (new CustomFieldValue)->getTable();
34+
$entityTypes = DB::table($table)
35+
->select('entity_type')
36+
->distinct()
37+
->pluck('entity_type');
38+
39+
if ($entityTypes->isEmpty()) {
40+
$this->info('No custom field values found.');
41+
42+
return self::SUCCESS;
43+
}
44+
45+
$morphMap = Relation::morphMap();
46+
$totalOrphaned = 0;
47+
$rows = [];
48+
49+
foreach ($entityTypes as $type) {
50+
$class = $morphMap[$type] ?? $type;
51+
52+
if (! class_exists($class)) {
53+
$this->warn("Skipping unknown entity type: {$type}");
54+
55+
continue;
56+
}
57+
58+
/** @var Model $model */
59+
$model = new $class;
60+
$entityTable = $model->getTable();
61+
62+
$orphanedCount = DB::table($table)
63+
->where('entity_type', $type)
64+
->whereNotExists(function ($query) use ($entityTable) {
65+
$query->select(DB::raw(1))
66+
->from($entityTable)
67+
->whereColumn("{$entityTable}.id", 'custom_field_values.entity_id');
68+
})
69+
->count();
70+
71+
if ($orphanedCount > 0) {
72+
$rows[] = [$type, $orphanedCount];
73+
$totalOrphaned += $orphanedCount;
74+
}
75+
}
76+
77+
if ($totalOrphaned === 0) {
78+
$this->info('No orphaned custom field values found.');
79+
80+
return self::SUCCESS;
81+
}
82+
83+
$this->table(['Entity Type', 'Orphaned Values'], $rows);
84+
$this->newLine();
85+
$this->line("Total orphaned values: {$totalOrphaned}");
86+
87+
if ($isDryRun) {
88+
$this->newLine();
89+
$this->info('DRY RUN COMPLETE - No changes were made');
90+
91+
return self::SUCCESS;
92+
}
93+
94+
if (! $isForced && ! $this->confirm('Delete these orphaned values?')) {
95+
$this->info('Cancelled.');
96+
97+
return self::SUCCESS;
98+
}
99+
100+
$deleted = 0;
101+
102+
foreach ($rows as [$type]) {
103+
$class = $morphMap[$type] ?? $type;
104+
/** @var Model $model */
105+
$model = new $class;
106+
$entityTable = $model->getTable();
107+
108+
$count = DB::table($table)
109+
->where('entity_type', $type)
110+
->whereNotExists(function ($query) use ($entityTable) {
111+
$query->select(DB::raw(1))
112+
->from($entityTable)
113+
->whereColumn("{$entityTable}.id", 'custom_field_values.entity_id');
114+
})
115+
->delete();
116+
117+
$this->info("Deleted {$count} orphaned values for {$type}.");
118+
$deleted += $count;
119+
}
120+
121+
$this->newLine();
122+
$this->comment("Cleaned up {$deleted} orphaned custom field values.");
123+
124+
return self::SUCCESS;
125+
}
126+
}

src/CustomFieldsServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use Illuminate\Database\Eloquent\Model;
1414
use Illuminate\Filesystem\Filesystem;
1515
use Livewire\Livewire;
16+
use Relaticle\CustomFields\Console\Commands\CleanupOrphanedValuesCommand;
1617
use Relaticle\CustomFields\Console\Commands\MakeCustomFieldsMigrationCommand;
1718
use Relaticle\CustomFields\Console\Commands\MakeFieldTypeCommand;
1819
use Relaticle\CustomFields\Console\Commands\UpgradeCommand;
@@ -167,6 +168,7 @@ private function getAssets(): array
167168
private function getCommands(): array
168169
{
169170
return [
171+
CleanupOrphanedValuesCommand::class,
170172
MakeCustomFieldsMigrationCommand::class,
171173
MakeFieldTypeCommand::class,
172174
UpgradeCommand::class,
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Illuminate\Support\Facades\DB;
6+
use Relaticle\CustomFields\Models\CustomField;
7+
use Relaticle\CustomFields\Models\CustomFieldValue;
8+
use Relaticle\CustomFields\Tests\Fixtures\Models\Comment;
9+
use Relaticle\CustomFields\Tests\Fixtures\Models\Post;
10+
use Relaticle\CustomFields\Tests\Fixtures\Models\User;
11+
12+
beforeEach(function (): void {
13+
$this->user = User::factory()->create();
14+
$this->actingAs($this->user);
15+
});
16+
17+
function deleteEntityWithoutEvents(string $table, mixed $id): void
18+
{
19+
DB::table($table)->where('id', $id)->delete();
20+
}
21+
22+
it('reports no orphaned values when none exist', function (): void {
23+
$this->artisan('custom-fields:cleanup-orphaned-values', ['--force' => true])
24+
->expectsOutput('No custom field values found.')
25+
->assertSuccessful();
26+
});
27+
28+
it('reports no orphaned values when all entities exist', function (): void {
29+
$post = Post::factory()->create();
30+
$customField = CustomField::factory()->create(['entity_type' => Post::class]);
31+
32+
CustomFieldValue::factory()->create([
33+
'entity_id' => $post->getKey(),
34+
'entity_type' => Post::class,
35+
'custom_field_id' => $customField->getKey(),
36+
]);
37+
38+
$this->artisan('custom-fields:cleanup-orphaned-values', ['--force' => true])
39+
->expectsOutput('No orphaned custom field values found.')
40+
->assertSuccessful();
41+
});
42+
43+
it('detects orphaned values in dry-run mode without deleting', function (): void {
44+
$post = Post::factory()->create();
45+
$customField = CustomField::factory()->create(['entity_type' => Post::class]);
46+
47+
CustomFieldValue::factory()->create([
48+
'entity_id' => $post->getKey(),
49+
'entity_type' => Post::class,
50+
'custom_field_id' => $customField->getKey(),
51+
]);
52+
53+
deleteEntityWithoutEvents('posts', $post->getKey());
54+
55+
$this->artisan('custom-fields:cleanup-orphaned-values', ['--dry-run' => true])
56+
->assertSuccessful();
57+
58+
expect(CustomFieldValue::withoutGlobalScopes()->count())->toBe(1);
59+
});
60+
61+
it('deletes orphaned values when entity is hard-deleted', function (): void {
62+
$post = Post::factory()->create();
63+
$customField = CustomField::factory()->create(['entity_type' => Post::class]);
64+
65+
CustomFieldValue::factory()->create([
66+
'entity_id' => $post->getKey(),
67+
'entity_type' => Post::class,
68+
'custom_field_id' => $customField->getKey(),
69+
]);
70+
71+
deleteEntityWithoutEvents('posts', $post->getKey());
72+
73+
$this->artisan('custom-fields:cleanup-orphaned-values', ['--force' => true])
74+
->assertSuccessful();
75+
76+
expect(CustomFieldValue::withoutGlobalScopes()->count())->toBe(0);
77+
});
78+
79+
it('preserves values for soft-deleted entities', function (): void {
80+
$post = Post::factory()->create();
81+
$customField = CustomField::factory()->create(['entity_type' => Post::class]);
82+
83+
CustomFieldValue::factory()->create([
84+
'entity_id' => $post->getKey(),
85+
'entity_type' => Post::class,
86+
'custom_field_id' => $customField->getKey(),
87+
]);
88+
89+
$post->delete();
90+
91+
$this->artisan('custom-fields:cleanup-orphaned-values', ['--force' => true])
92+
->expectsOutput('No orphaned custom field values found.')
93+
->assertSuccessful();
94+
95+
expect(CustomFieldValue::withoutGlobalScopes()->count())->toBe(1);
96+
});
97+
98+
it('deletes orphaned values for entities without soft deletes', function (): void {
99+
$comment = Comment::factory()->create();
100+
$customField = CustomField::factory()->create(['entity_type' => Comment::class]);
101+
102+
CustomFieldValue::factory()->create([
103+
'entity_id' => $comment->getKey(),
104+
'entity_type' => Comment::class,
105+
'custom_field_id' => $customField->getKey(),
106+
]);
107+
108+
deleteEntityWithoutEvents('comments', $comment->getKey());
109+
110+
$this->artisan('custom-fields:cleanup-orphaned-values', ['--force' => true])
111+
->assertSuccessful();
112+
113+
expect(CustomFieldValue::withoutGlobalScopes()->count())->toBe(0);
114+
});
115+
116+
it('only deletes orphaned values and preserves valid ones', function (): void {
117+
$activePost = Post::factory()->create();
118+
$deletedPost = Post::factory()->create();
119+
$customField = CustomField::factory()->create(['entity_type' => Post::class]);
120+
121+
CustomFieldValue::factory()->create([
122+
'entity_id' => $activePost->getKey(),
123+
'entity_type' => Post::class,
124+
'custom_field_id' => $customField->getKey(),
125+
]);
126+
127+
CustomFieldValue::factory()->create([
128+
'entity_id' => $deletedPost->getKey(),
129+
'entity_type' => Post::class,
130+
'custom_field_id' => $customField->getKey(),
131+
]);
132+
133+
deleteEntityWithoutEvents('posts', $deletedPost->getKey());
134+
135+
$this->artisan('custom-fields:cleanup-orphaned-values', ['--force' => true])
136+
->assertSuccessful();
137+
138+
expect(CustomFieldValue::withoutGlobalScopes()->count())->toBe(1);
139+
expect(
140+
CustomFieldValue::withoutGlobalScopes()
141+
->where('entity_id', $activePost->getKey())
142+
->exists()
143+
)->toBeTrue();
144+
});

0 commit comments

Comments
 (0)