Files
SkinbaseNova/app/Services/ArtworkRelatedService.php
T
klevze a5e51617a6 Extract related artwork lookup into a dedicated service.
Reuse the same related-artwork ranking on similar-art pages and keep category and tag matches from duplicating results.
2026-09-20 14:49:32 +02:00

164 lines
6.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\User;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class ArtworkRelatedService
{
private const SPLIT_PIVOT_ROW_THRESHOLD = 1000;
private const RELATIONS = ['user', 'group', 'categories.contentType'];
public function __construct(private readonly ArtworkMaturityService $maturity) {}
/**
* Return the related artwork models used by the artwork page.
*
* The fallback query is intentionally kept equivalent to the controller's
* original OR/EXISTS query. The split path only changes how candidates are
* found; the final models and their relations remain the same.
*/
public function related(Artwork $artwork, ?User $viewer, int $limit = 12): EloquentCollection
{
$categoryIds = $artwork->categories->pluck('id')->filter()->values();
$tagIds = $artwork->tags->pluck('id')->filter()->values();
if ($categoryIds->isEmpty() || $this->matchingPivotRows($categoryIds) > self::SPLIT_PIVOT_ROW_THRESHOLD) {
return $this->originalQuery($artwork, $viewer, $categoryIds, $tagIds, $limit)->get();
}
$candidateRows = collect()
->merge($this->topAuthorCandidates($artwork, $viewer, $limit))
->merge($this->topGroupCandidates($artwork, $viewer, $limit))
->merge($this->topCategoryCandidates($artwork, $viewer, $categoryIds, $limit))
->merge($this->topTagCandidates($artwork, $viewer, $tagIds, $limit))
->reject(fn (object $row): bool => (int) $row->id === (int) $artwork->id)
->unique(fn (object $row): string => (string) $row->id)
->sortByDesc(fn (object $row): mixed => $row->published_at)
->take($limit)
->values();
$ids = $candidateRows->pluck('id')->map(static fn ($id): int => (int) $id)->all();
if ($ids === []) {
return new EloquentCollection;
}
$hydrated = Artwork::query()
->with(self::RELATIONS)
->whereIn('id', $ids)
->tap(fn (Builder $builder) => $this->maturity->applyViewerFilter($builder, $viewer))
->get()
->keyBy('id');
return new EloquentCollection(collect($ids)
->map(fn (int $id): ?Artwork => $hydrated->get($id))
->filter()
->values()
->all());
}
private function matchingPivotRows(Collection $categoryIds): int
{
return (int) DB::table('artwork_category')
->whereIn('category_id', $categoryIds->all())
->count();
}
private function candidateQuery(Artwork $artwork, ?User $viewer): Builder
{
return Artwork::query()
->select(['artworks.id', 'artworks.published_at'])
->whereKeyNot($artwork->id)
->public()
->published()
->tap(fn (Builder $builder) => $this->maturity->applyViewerFilter($builder, $viewer));
}
private function topAuthorCandidates(Artwork $artwork, ?User $viewer, int $limit): Collection
{
return $this->candidateQuery($artwork, $viewer)
->where('user_id', $artwork->user_id)
->latest('published_at')
->limit($limit)
->get();
}
private function topGroupCandidates(Artwork $artwork, ?User $viewer, int $limit): Collection
{
if (! $artwork->group_id) {
return collect();
}
return $this->candidateQuery($artwork, $viewer)
->where('group_id', $artwork->group_id)
->latest('published_at')
->limit($limit)
->get();
}
private function topCategoryCandidates(Artwork $artwork, ?User $viewer, Collection $categoryIds, int $limit): Collection
{
return $this->candidateQuery($artwork, $viewer)
->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
->whereIn('artwork_category.category_id', $categoryIds->all())
->select(['artworks.id', 'artworks.published_at'])
->distinct()
->orderByDesc('artworks.published_at')
->limit($limit)
->get();
}
private function topTagCandidates(Artwork $artwork, ?User $viewer, Collection $tagIds, int $limit): Collection
{
if ($tagIds->isEmpty()) {
return collect();
}
return $this->candidateQuery($artwork, $viewer)
->whereHas('tags', fn ($query) => $query->whereIn('tags.id', $tagIds->all()))
->latest('published_at')
->limit($limit)
->get();
}
private function originalQuery(Artwork $artwork, ?User $viewer, Collection $categoryIds, Collection $tagIds, int $limit): Builder
{
return Artwork::query()
->with(self::RELATIONS)
->whereKeyNot($artwork->id)
->public()
->published()
->tap(fn (Builder $builder) => $this->maturity->applyViewerFilter($builder, $viewer))
->where(function ($query) use ($artwork, $categoryIds, $tagIds): void {
$query->where('user_id', $artwork->user_id);
if ($artwork->group_id) {
$query->orWhere('group_id', $artwork->group_id);
}
if ($categoryIds->isNotEmpty()) {
$query->orWhereHas('categories', function ($categoryQuery) use ($categoryIds): void {
$categoryQuery->whereIn('categories.id', $categoryIds->all());
});
}
if ($tagIds->isNotEmpty()) {
$query->orWhereHas('tags', function ($tagQuery) use ($tagIds): void {
$tagQuery->whereIn('tags.id', $tagIds->all());
});
}
})
->latest('published_at')
->limit($limit);
}
}