Reuse the same related-artwork ranking on similar-art pages and keep category and tag matches from duplicating results.
164 lines
6.0 KiB
PHP
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);
|
|
}
|
|
}
|