Feat: Product Search Service Restructure
This commit is contained in:
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace Modules\Core\Catalog\Services;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Lunar\Facades\AttributeManifest;
|
||||
use Lunar\Models\Language;
|
||||
use Lunar\Models\Product;
|
||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||
use Modules\Core\Catalog\DTOs\ProductListingResult;
|
||||
use Modules\Core\Catalog\Enums\ProductSort;
|
||||
use Modules\Core\Catalog\Support\ProductDocumentLocalizer;
|
||||
use Modules\Core\Catalog\Support\ProductFilterBuilder;
|
||||
|
||||
/**
|
||||
@@ -21,20 +23,37 @@ class ProductSearchService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProductFilterBuilder $filterBuilder,
|
||||
private readonly ProductDocumentLocalizer $localizer,
|
||||
private readonly ProductService $products,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns the exact same Modules\Core\Catalog\DTOs\ProductListingResult
|
||||
* ProductService::list() does — a search results page and a category
|
||||
* listing page consume identically shaped data, one call each. The
|
||||
* paginator itself carries plain, localized indexed-document arrays
|
||||
* (not hydrated Product models), same as list().
|
||||
*
|
||||
* priceBounds/availableTags are delegated to ProductService's own
|
||||
* priceSliderBounds()/availableTags() rather than reimplemented here —
|
||||
* both already accept a $query param for exactly this reason (a search
|
||||
* page's slider/tag sidebar should reflect only the products search
|
||||
* actually matched, not the whole catalog).
|
||||
*
|
||||
* $filters/$sort apply the exact same semantics ProductService::list()
|
||||
* uses for collection browsing (same ProductFilterBuilder, same
|
||||
* ProductSort::toMeilisearchSort()) — a shopper narrowing a text search
|
||||
* by price/brand/stock gets identical filter behavior to narrowing a
|
||||
* category listing, since both go through the same Meilisearch `filter`
|
||||
* clause underneath.
|
||||
*
|
||||
* @return Collection<int, Product>
|
||||
*/
|
||||
public function search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null): Collection
|
||||
{
|
||||
public function search(
|
||||
string $query,
|
||||
?ProductFilters $filters = null,
|
||||
?ProductSort $sort = null,
|
||||
int $perPage = 24,
|
||||
int $page = 1,
|
||||
): ProductListingResult {
|
||||
$options = [
|
||||
'attributesToSearchOn' => $this->searchableFields(),
|
||||
'filter' => $this->filterBuilder->build($filters),
|
||||
@@ -44,9 +63,26 @@ class ProductSearchService
|
||||
$options['sort'] = [$sort->toMeilisearchSort()];
|
||||
}
|
||||
|
||||
return Product::search($query)
|
||||
$paginator = Product::search($query)
|
||||
->options($options)
|
||||
->get();
|
||||
->paginateRaw(perPage: $perPage, page: $page);
|
||||
|
||||
$data = collect($this->localizer->hitsFrom($paginator))
|
||||
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
|
||||
->all();
|
||||
|
||||
$products = new LengthAwarePaginator(
|
||||
items: $data,
|
||||
total: $paginator->total(),
|
||||
perPage: $paginator->perPage(),
|
||||
currentPage: $paginator->currentPage(),
|
||||
options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
|
||||
);
|
||||
|
||||
$priceBounds = $this->products->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice, $query);
|
||||
$availableTags = $this->products->availableTags($filters, $query);
|
||||
|
||||
return new ProductListingResult($products, $priceBounds, $availableTags);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,17 +2,13 @@
|
||||
|
||||
namespace Modules\Core\Catalog\Services;
|
||||
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Lunar\Base\AttributeManifest;
|
||||
use Lunar\FieldTypes\TranslatedText;
|
||||
use Lunar\Models\Product;
|
||||
use Modules\Core\Localization\Services\LanguageCache;
|
||||
use Modules\Core\Catalog\DTOs\PriceSliderBounds;
|
||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||
use Modules\Core\Catalog\DTOs\ProductListingResult;
|
||||
use Modules\Core\Catalog\Enums\ProductSort;
|
||||
use Modules\Core\Catalog\Support\ProductDocumentLocalizer;
|
||||
use Modules\Core\Catalog\Support\ProductFilterBuilder;
|
||||
|
||||
/**
|
||||
@@ -27,8 +23,7 @@ use Modules\Core\Catalog\Support\ProductFilterBuilder;
|
||||
class ProductService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LanguageCache $languages,
|
||||
private readonly AttributeManifest $attributes,
|
||||
private readonly ProductDocumentLocalizer $localizer,
|
||||
private readonly ProductFilterBuilder $filterBuilder,
|
||||
) {}
|
||||
|
||||
@@ -65,8 +60,8 @@ class ProductService
|
||||
->options($options)
|
||||
->paginateRaw(perPage: $perPage, page: $page);
|
||||
|
||||
$data = collect($this->hitsFrom($paginator))
|
||||
->map(fn (array $product) => $this->withLocalizedFields($product))
|
||||
$data = collect($this->localizer->hitsFrom($paginator))
|
||||
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
|
||||
->all();
|
||||
|
||||
$products = new LengthAwarePaginator(
|
||||
@@ -91,12 +86,20 @@ class ProductService
|
||||
* alphabetically; Meilisearch's facetDistribution has no defined order
|
||||
* of its own.
|
||||
*
|
||||
* $query defaults to '' (every product, same as list()'s own default
|
||||
* text query) — same reasoning as priceRange()'s own $query: pass the
|
||||
* shopper's search text here too so a search page's own tag sidebar
|
||||
* reflects only the products search actually matched. Public (not
|
||||
* private, unlike the rest of this listing-only orchestration) so
|
||||
* ProductSearchService::search() can reuse it directly rather than
|
||||
* reimplementing the same facet call a second time.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function availableTags(?ProductFilters $filters): array
|
||||
public function availableTags(?ProductFilters $filters, string $query = ''): array
|
||||
{
|
||||
$filter = $this->filterBuilder->build($filters, exclude: ['tag']);
|
||||
$tags = $this->rawFacets('tags', $filter)['facetDistribution']['tags'] ?? [];
|
||||
$tags = $this->rawFacets('tags', $filter, $query)['facetDistribution']['tags'] ?? [];
|
||||
|
||||
return collect($tags)->keys()->sort()->values()->all();
|
||||
}
|
||||
@@ -287,69 +290,8 @@ class ProductService
|
||||
->options(['filter' => $filter])
|
||||
->paginateRaw(perPage: $limit, page: 1);
|
||||
|
||||
return collect($this->hitsFrom($paginator))
|
||||
->map(fn (array $product) => $this->withLocalizedFields($product))
|
||||
return collect($this->localizer->hitsFrom($paginator))
|
||||
->map(fn (array $product) => $this->localizer->withLocalizedFields($product))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves every translated Product attribute's current-locale value from the
|
||||
* indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`,
|
||||
* `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's
|
||||
* default language (LanguageCache::defaultLocale()) when the current locale
|
||||
* has no translation - e.g. a product with no English copy yet still shows its
|
||||
* Greek name on /en/ rather than rendering blank.
|
||||
*
|
||||
* Which handles are translated is read from AttributeManifest - the same
|
||||
* source Lunar's own ScoutIndexer reads when exploding a TranslatedText
|
||||
* attribute into `{handle}_{locale}` keys at index time - rather than a fixed
|
||||
* list, so a store's own custom translated attributes (e.g. `seo_title`) are
|
||||
* picked up automatically with no change here. The raw per-locale keys are
|
||||
* then stripped, since once resolved, callers only ever need the one that
|
||||
* matched the current locale.
|
||||
*
|
||||
* Deliberately not config('app.locale') - App::setLocale() overwrites that
|
||||
* config value on every request, so by request time it's just whatever the
|
||||
* current locale already is, not a stable fallback.
|
||||
*/
|
||||
private function withLocalizedFields(array $product): array
|
||||
{
|
||||
$locale = App::getLocale();
|
||||
$fallbackLocale = $this->languages->defaultLocale();
|
||||
$availableLocales = $this->languages->availableLocales();
|
||||
|
||||
foreach ($this->translatedAttributeHandles() as $handle) {
|
||||
$product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null;
|
||||
|
||||
foreach ($availableLocales as $availableLocale) {
|
||||
unset($product[$handle.'_'.$availableLocale]);
|
||||
}
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function translatedAttributeHandles(): array
|
||||
{
|
||||
return $this->attributes->getSearchableAttributes((new Product)->getMorphClass())
|
||||
->filter(fn ($attribute) => $attribute->type === TranslatedText::class)
|
||||
->pluck('handle')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
|
||||
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
|
||||
* actual documents are under the 'hits' key.
|
||||
*/
|
||||
private function hitsFrom(LengthAwarePaginatorContract $paginator): array
|
||||
{
|
||||
$rawResponse = $paginator->items();
|
||||
|
||||
return collect($rawResponse['hits'] ?? [])->values()->all();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Support;
|
||||
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Lunar\Base\AttributeManifest;
|
||||
use Lunar\FieldTypes\TranslatedText;
|
||||
use Lunar\Models\Product;
|
||||
use Modules\Core\Localization\Services\LanguageCache;
|
||||
|
||||
/**
|
||||
* Shared between Modules\Core\Catalog\Services\ProductService and
|
||||
* ProductSearchService — both read the same kind of Meilisearch document
|
||||
* (Modules\Core\Catalog\Services\ProductIndexer's shape) and need the
|
||||
* exact same per-locale field resolution and raw-response unwrapping.
|
||||
* Extracted rather than duplicated so a future fix to the localization-
|
||||
* fallback logic only needs to be made once.
|
||||
*/
|
||||
class ProductDocumentLocalizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LanguageCache $languages,
|
||||
private readonly AttributeManifest $attributes,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolves every translated Product attribute's current-locale value from the
|
||||
* indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`,
|
||||
* `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's
|
||||
* default language (LanguageCache::defaultLocale()) when the current locale
|
||||
* has no translation - e.g. a product with no English copy yet still shows its
|
||||
* Greek name on /en/ rather than rendering blank.
|
||||
*
|
||||
* Which handles are translated is read from AttributeManifest - the same
|
||||
* source Lunar's own ScoutIndexer reads when exploding a TranslatedText
|
||||
* attribute into `{handle}_{locale}` keys at index time - rather than a fixed
|
||||
* list, so a store's own custom translated attributes (e.g. `seo_title`) are
|
||||
* picked up automatically with no change here. The raw per-locale keys are
|
||||
* then stripped, since once resolved, callers only ever need the one that
|
||||
* matched the current locale.
|
||||
*
|
||||
* Deliberately not config('app.locale') - App::setLocale() overwrites that
|
||||
* config value on every request, so by request time it's just whatever the
|
||||
* current locale already is, not a stable fallback.
|
||||
*/
|
||||
public function withLocalizedFields(array $product): array
|
||||
{
|
||||
$locale = App::getLocale();
|
||||
$fallbackLocale = $this->languages->defaultLocale();
|
||||
$availableLocales = $this->languages->availableLocales();
|
||||
|
||||
foreach ($this->translatedAttributeHandles() as $handle) {
|
||||
$product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null;
|
||||
|
||||
foreach ($availableLocales as $availableLocale) {
|
||||
unset($product[$handle.'_'.$availableLocale]);
|
||||
}
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
|
||||
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
|
||||
* actual documents are under the 'hits' key.
|
||||
*/
|
||||
public function hitsFrom(LengthAwarePaginatorContract $paginator): array
|
||||
{
|
||||
$rawResponse = $paginator->items();
|
||||
|
||||
return collect($rawResponse['hits'] ?? [])->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function translatedAttributeHandles(): array
|
||||
{
|
||||
return $this->attributes->getSearchableAttributes((new Product)->getMorphClass())
|
||||
->filter(fn ($attribute) => $attribute->type === TranslatedText::class)
|
||||
->pluck('handle')
|
||||
->all();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user