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.
This commit is contained in:
@@ -171,16 +171,13 @@ final class SimilarArtworksPageController extends Controller
|
|||||||
*/
|
*/
|
||||||
private function resolveSimilarArtworks(Artwork $source, int $page, string $baseUrl): array
|
private function resolveSimilarArtworks(Artwork $source, int $page, string $baseUrl): array
|
||||||
{
|
{
|
||||||
// Priority 1 — Qdrant visual (vision) similarity
|
// Priority 1 — Qdrant visual (vision) similarity.
|
||||||
|
// Paginate IDs before hydrating/presenting full Artwork models so a
|
||||||
|
// request only pays the relation/presenter cost for the current page.
|
||||||
if ($this->vectors->isConfigured()) {
|
if ($this->vectors->isConfigured()) {
|
||||||
$qdrantItems = $this->resolveViaQdrant($source);
|
$qdrantPaginator = $this->resolveViaQdrant($source, $page, $baseUrl);
|
||||||
if ($qdrantItems !== null && $qdrantItems->isNotEmpty()) {
|
if ($qdrantPaginator !== null && $qdrantPaginator->total() > 0) {
|
||||||
$paginator = $this->paginateCollection(
|
return [$qdrantPaginator, 'visual'];
|
||||||
$qdrantItems->map(fn ($a) => $this->presentArtwork($a)),
|
|
||||||
$page,
|
|
||||||
$baseUrl,
|
|
||||||
);
|
|
||||||
return [$paginator, 'visual'];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,38 +189,77 @@ final class SimilarArtworksPageController extends Controller
|
|||||||
$page,
|
$page,
|
||||||
$baseUrl,
|
$baseUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
return [$paginator, 'hybrid'];
|
return [$paginator, 'hybrid'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3 — Meilisearch tag/category overlap
|
// Priority 3 — Meilisearch tag/category overlap
|
||||||
$paginator = $this->meilisearchFallback($source, $page);
|
$paginator = $this->meilisearchFallback($source, $page);
|
||||||
|
|
||||||
return [$paginator, 'tags'];
|
return [$paginator, 'tags'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query Qdrant via VectorGateway, then re-hydrate full Artwork models
|
* Query Qdrant via VectorGateway, preserve relevance order, then paginate
|
||||||
* (so we have category/dimension data for the masonry grid).
|
* candidate IDs before hydrating full Artwork models for the current page.
|
||||||
*
|
*
|
||||||
* Returns null when the gateway call fails, so the caller can fall through.
|
* The light eligibility query keeps the existing public/published semantics
|
||||||
|
* for cached Qdrant results without loading relations for every candidate.
|
||||||
|
* Returns null when the gateway call fails or no eligible candidates remain,
|
||||||
|
* allowing the caller to continue to the existing hybrid/tag fallbacks.
|
||||||
*/
|
*/
|
||||||
private function resolveViaQdrant(Artwork $source): ?Collection
|
private function resolveViaQdrant(
|
||||||
{
|
Artwork $source,
|
||||||
|
int $page,
|
||||||
|
string $baseUrl,
|
||||||
|
): ?LengthAwarePaginator {
|
||||||
try {
|
try {
|
||||||
$raw = $this->vectors->similarToArtwork($source, self::QDRANT_LIMIT);
|
$raw = $this->vectors->similarToArtwork($source, self::QDRANT_LIMIT);
|
||||||
} catch (RuntimeException) {
|
} catch (RuntimeException) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($raw)) {
|
if ($raw === []) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve Qdrant relevance order; IDs are already filtered to public+published
|
// Preserve Qdrant relevance order while normalising IDs defensively.
|
||||||
$orderedIds = array_column($raw, 'id');
|
$orderedIds = collect(array_column($raw, 'id'))
|
||||||
|
->map(static fn ($id): int => is_numeric($id) ? (int) $id : 0)
|
||||||
|
->filter(static fn (int $id): bool => $id > 0 && $id !== (int) $source->id)
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($orderedIds->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep cached Qdrant results honest if an artwork has since become
|
||||||
|
// private/unpublished. Fetch IDs only; do not hydrate relations yet.
|
||||||
|
$eligibleSet = Artwork::query()
|
||||||
|
->whereIn('id', $orderedIds->all())
|
||||||
|
->public()
|
||||||
|
->published()
|
||||||
|
->pluck('id')
|
||||||
|
->mapWithKeys(static fn ($id): array => [(int) $id => true]);
|
||||||
|
|
||||||
|
$eligibleIds = $orderedIds
|
||||||
|
->filter(static fn (int $id): bool => $eligibleSet->has($id))
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($eligibleIds->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$perPage = self::PER_PAGE;
|
||||||
|
$total = $eligibleIds->count();
|
||||||
|
$pageIds = $eligibleIds->forPage($page, $perPage)->values();
|
||||||
|
|
||||||
|
$items = collect();
|
||||||
|
|
||||||
|
if ($pageIds->isNotEmpty()) {
|
||||||
$artworks = Artwork::query()
|
$artworks = Artwork::query()
|
||||||
->whereIn('id', $orderedIds)
|
->whereIn('id', $pageIds->all())
|
||||||
->where('id', '!=', $source->id) // belt-and-braces exclusion
|
|
||||||
->public()
|
->public()
|
||||||
->published()
|
->published()
|
||||||
->with([
|
->with([
|
||||||
@@ -236,12 +272,25 @@ final class SimilarArtworksPageController extends Controller
|
|||||||
->get()
|
->get()
|
||||||
->keyBy('id');
|
->keyBy('id');
|
||||||
|
|
||||||
return collect($orderedIds)
|
$items = $pageIds
|
||||||
->map(fn (int $id) => $artworks->get($id))
|
->map(fn (int $id) => $artworks->get($id))
|
||||||
->filter()
|
->filter()
|
||||||
|
->map(fn (Artwork $artwork) => $this->presentArtwork($artwork))
|
||||||
->values();
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new LengthAwarePaginator(
|
||||||
|
$items,
|
||||||
|
$total,
|
||||||
|
$perPage,
|
||||||
|
$page,
|
||||||
|
[
|
||||||
|
'path' => $baseUrl,
|
||||||
|
'query' => [],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Meilisearch tag-overlap query with category fallback.
|
* Meilisearch tag-overlap query with category fallback.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\ContentType;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ArtworkRelatedService;
|
||||||
|
use App\Services\Maturity\ArtworkMaturityService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function relatedCategory(string $suffix = 'related-service'): Category
|
||||||
|
{
|
||||||
|
$type = ContentType::query()->create([
|
||||||
|
'name' => 'Related '.$suffix,
|
||||||
|
'slug' => $suffix.'-type',
|
||||||
|
'description' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Category::query()->create([
|
||||||
|
'content_type_id' => $type->id,
|
||||||
|
'name' => 'Related '.$suffix,
|
||||||
|
'slug' => $suffix,
|
||||||
|
'description' => '',
|
||||||
|
'is_active' => true,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relatedArtwork(User $user, string $slug, int $minutes): Artwork
|
||||||
|
{
|
||||||
|
return Artwork::factory()->for($user)->create([
|
||||||
|
'slug' => $slug,
|
||||||
|
'title' => $slug,
|
||||||
|
'published_at' => now()->subMinutes($minutes),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('uses the split path for rare categories, deduplicates, orders, excludes source, and loads page relations', function (): void {
|
||||||
|
$author = User::factory()->create();
|
||||||
|
$category = relatedCategory('rare');
|
||||||
|
$source = relatedArtwork($author, 'source', 100);
|
||||||
|
$source->categories()->attach($category);
|
||||||
|
|
||||||
|
$sameAuthor = relatedArtwork($author, 'same-author', 5);
|
||||||
|
$sameAuthor->categories()->attach($category);
|
||||||
|
$sameCategory = relatedArtwork(User::factory()->create(), 'same-category', 2);
|
||||||
|
$sameCategory->categories()->attach($category);
|
||||||
|
|
||||||
|
$queries = [];
|
||||||
|
DB::listen(function ($query) use (&$queries): void {
|
||||||
|
$queries[] = $query->sql;
|
||||||
|
});
|
||||||
|
|
||||||
|
$results = app(ArtworkRelatedService::class)->related($source->fresh(['categories', 'tags']), null);
|
||||||
|
|
||||||
|
expect($results->pluck('id')->all())->toBe([$sameCategory->id, $sameAuthor->id])
|
||||||
|
->and($results->first()->relationLoaded('user'))->toBeTrue()
|
||||||
|
->and($results->first()->relationLoaded('group'))->toBeTrue()
|
||||||
|
->and($results->first()->relationLoaded('categories'))->toBeTrue()
|
||||||
|
->and($results->first()->categories->first()->relationLoaded('contentType'))->toBeTrue()
|
||||||
|
->and(collect($queries)->contains(fn (string $sql): bool => str_contains(strtolower($sql), 'join') && str_contains(strtolower($sql), 'artwork_category')))->toBeTrue()
|
||||||
|
->and($results->pluck('id'))->not->toContain($source->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the original OR/EXISTS path when there are no categories', function (): void {
|
||||||
|
$author = User::factory()->create();
|
||||||
|
$source = relatedArtwork($author, 'source-without-category', 100);
|
||||||
|
$related = relatedArtwork($author, 'author-without-category', 1);
|
||||||
|
|
||||||
|
DB::enableQueryLog();
|
||||||
|
$results = app(ArtworkRelatedService::class)->related($source->fresh(['categories', 'tags']), null);
|
||||||
|
$sql = collect(DB::getQueryLog())->pluck('query')->implode("\n");
|
||||||
|
|
||||||
|
expect($results->pluck('id')->all())->toContain($related->id)
|
||||||
|
->and(strtolower($sql))->toContain('select');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the same maturity filter to split candidates for guests and viewers', function (): void {
|
||||||
|
config()->set('maturity.viewer.guest_mode', ArtworkMaturityService::VIEW_HIDE);
|
||||||
|
config()->set('maturity.viewer.default_mode', ArtworkMaturityService::VIEW_HIDE);
|
||||||
|
|
||||||
|
$author = User::factory()->create();
|
||||||
|
$category = relatedCategory('maturity');
|
||||||
|
$source = relatedArtwork($author, 'maturity-source', 100);
|
||||||
|
$source->categories()->attach($category);
|
||||||
|
$safe = relatedArtwork(User::factory()->create(), 'safe-related', 2);
|
||||||
|
$safe->categories()->attach($category);
|
||||||
|
$mature = relatedArtwork(User::factory()->create(), 'mature-related', 1);
|
||||||
|
$mature->forceFill(['is_mature' => true])->save();
|
||||||
|
$mature->categories()->attach($category);
|
||||||
|
|
||||||
|
$guestIds = app(ArtworkRelatedService::class)->related($source->fresh(['categories', 'tags']), null)->pluck('id');
|
||||||
|
expect($guestIds)->toContain($safe->id)->not->toContain($mature->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains the current query above the 1000 matching pivot-row threshold', function (): void {
|
||||||
|
$author = User::factory()->create();
|
||||||
|
$category = relatedCategory('threshold');
|
||||||
|
$source = relatedArtwork($author, 'threshold-source', 2000);
|
||||||
|
$source->categories()->attach($category);
|
||||||
|
|
||||||
|
$rows = Artwork::factory()->count(1000)->create()->each(function (Artwork $artwork) use ($category): void {
|
||||||
|
$artwork->categories()->attach($category);
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::enableQueryLog();
|
||||||
|
app(ArtworkRelatedService::class)->related($source->fresh(['categories', 'tags']), null);
|
||||||
|
$sql = collect(DB::getQueryLog())->pluck('query')->implode("\n");
|
||||||
|
|
||||||
|
expect($rows)->toHaveCount(1000)
|
||||||
|
->and(strtolower($sql))->toContain('exists (select');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user