Allow artworks to have one primary category and extra secondaries.
Keep artwork_category as membership, store a deterministic primary_category_id, and let upload plus studio pick up to five additional same-type categories without changing canonical URLs.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class BackfillArtworkPrimaryCategoryCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:backfill-primary-category
|
||||
{--dry-run : Report without writing (default unless --apply is passed)}
|
||||
{--report : Print category-count distribution and conflicts}
|
||||
{--apply : Write primary_category_id for artworks with exactly one category}
|
||||
{--force : Overwrite existing primary_category_id values}';
|
||||
|
||||
protected $description = 'Backfill artworks.primary_category_id from unique artwork_category memberships';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$apply = (bool) $this->option('apply');
|
||||
$force = (bool) $this->option('force');
|
||||
$report = (bool) $this->option('report') || ! $apply;
|
||||
|
||||
if ($report) {
|
||||
$this->printDistribution();
|
||||
}
|
||||
|
||||
$conflicts = $this->conflictArtworkIds();
|
||||
$zeros = $this->zeroCategoryArtworkIds();
|
||||
$eligible = $this->eligibleArtworkIds($force);
|
||||
|
||||
$this->info('Zero-category artworks: '.$zeros->count());
|
||||
$this->info('Single-category eligible for backfill: '.$eligible->count());
|
||||
$this->info('Multi-category conflicts (not auto-resolved): '.$conflicts->count());
|
||||
|
||||
if ($conflicts->isNotEmpty()) {
|
||||
$this->warn('Multi-category artwork IDs (first 50): '.$conflicts->take(50)->implode(', '));
|
||||
}
|
||||
|
||||
if (! $apply) {
|
||||
$this->comment('Dry run only. Re-run with --apply to write primary_category_id for single-category artworks.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
foreach ($eligible->chunk(500) as $chunk) {
|
||||
$rows = DB::table('artwork_category')
|
||||
->select('artwork_id', 'category_id')
|
||||
->whereIn('artwork_id', $chunk->all())
|
||||
->get()
|
||||
->groupBy('artwork_id');
|
||||
|
||||
foreach ($rows as $artworkId => $memberships) {
|
||||
if ($memberships->count() !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$query = Artwork::query()->where('id', (int) $artworkId);
|
||||
if (! $force) {
|
||||
$query->whereNull('primary_category_id');
|
||||
}
|
||||
|
||||
$updated += $query->update([
|
||||
'primary_category_id' => (int) $memberships->first()->category_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Updated {$updated} artwork(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function printDistribution(): void
|
||||
{
|
||||
$distribution = DB::query()
|
||||
->fromSub(function ($query): void {
|
||||
$query->from('artwork_category')
|
||||
->select('artwork_id', DB::raw('COUNT(*) as category_count'))
|
||||
->groupBy('artwork_id');
|
||||
}, 'x')
|
||||
->select('category_count', DB::raw('COUNT(*) as artworks'))
|
||||
->groupBy('category_count')
|
||||
->orderBy('category_count')
|
||||
->get();
|
||||
|
||||
$this->table(['category_count', 'artworks'], $distribution->map(fn ($row): array => [
|
||||
(int) $row->category_count,
|
||||
(int) $row->artworks,
|
||||
])->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, int>
|
||||
*/
|
||||
private function eligibleArtworkIds(bool $force)
|
||||
{
|
||||
$query = DB::table('artwork_category')
|
||||
->select('artwork_id')
|
||||
->groupBy('artwork_id')
|
||||
->havingRaw('COUNT(*) = 1');
|
||||
|
||||
if (! $force) {
|
||||
$query->whereIn('artwork_id', Artwork::query()->whereNull('primary_category_id')->select('id'));
|
||||
}
|
||||
|
||||
return $query->pluck('artwork_id')->map(fn ($id): int => (int) $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, int>
|
||||
*/
|
||||
private function conflictArtworkIds()
|
||||
{
|
||||
return DB::table('artwork_category')
|
||||
->select('artwork_id')
|
||||
->groupBy('artwork_id')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->pluck('artwork_id')
|
||||
->map(fn ($id): int => (int) $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, int>
|
||||
*/
|
||||
private function zeroCategoryArtworkIds()
|
||||
{
|
||||
return Artwork::query()
|
||||
->whereDoesntHave('categories')
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user