From b2919f1f4b38e66679422b402b44d14048208024 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 3 Sep 2026 11:04:19 +0300 Subject: [PATCH] Feature: Product Search Service Updates --- src/Catalog/Services/ProductSearchService.php | 62 ++++++++++++----- src/Catalog/Services/ProductService.php | 46 ++++--------- src/Catalog/Support/ProductFilterBuilder.php | 40 +++++++++++ src/Command/TuneProductSearchCommand.php | 67 +++++++++++++++++++ src/Providers/CoreServiceProvider.php | 3 +- 5 files changed, 170 insertions(+), 48 deletions(-) create mode 100644 src/Catalog/Support/ProductFilterBuilder.php create mode 100644 src/Command/TuneProductSearchCommand.php diff --git a/src/Catalog/Services/ProductSearchService.php b/src/Catalog/Services/ProductSearchService.php index 0ae8e9e..8686699 100644 --- a/src/Catalog/Services/ProductSearchService.php +++ b/src/Catalog/Services/ProductSearchService.php @@ -3,10 +3,12 @@ namespace Modules\Core\Catalog\Services; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Support\Facades\App; use Lunar\Facades\AttributeManifest; use Lunar\Models\Language; use Lunar\Models\Product; +use Modules\Core\Catalog\DTOs\ProductFilters; +use Modules\Core\Catalog\Enums\ProductSort; +use Modules\Core\Catalog\Support\ProductFilterBuilder; /** * Lunar's Meilisearch indexer flattens translated attributes into locale-suffixed @@ -17,39 +19,69 @@ use Lunar\Models\Product; */ class ProductSearchService { + public function __construct( + private readonly ProductFilterBuilder $filterBuilder, + ) {} + /** + * $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 */ - public function search(string $query, ?string $locale = null): Collection + public function search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null): Collection { - $locale ??= App::getLocale(); - $defaultLocale = Language::getDefault()->code; + $options = [ + 'attributesToSearchOn' => $this->searchableFields(), + 'filter' => $this->filterBuilder->build($filters), + ]; + + if ($sort !== null) { + $options['sort'] = [$sort->toMeilisearchSort()]; + } return Product::search($query) - ->options([ - 'attributesToSearchOn' => $this->searchableFields($locale, $defaultLocale), - ]) + ->options($options) ->get(); } /** - * Target the resolved locale's fields plus the default locale's fields, so a - * product that's only ever been translated into the default language still - * surfaces when searched in another locale, instead of becoming invisible - * until every product is fully translated. + * Targets every configured store language's fields, not just the current + * request locale plus the store default — a shopper browsing in Greek + * typing an English word (or vice versa) should still match a product + * whose only translation for that text happens to be in a third + * language. There's no per-request "current locale" concept in this + * method any more: which fields exist to search on is a property of the + * store's configured languages, not of who's asking. + * + * Also targets variants.options.value directly — a variant's option + * value (e.g. "Κάπτεν Γαμέρικα" on a "Name" option) is how ProductIndexer + * already indexes it (see mapVariant()), but it isn't one of Lunar's own + * attributes, so it can't come from AttributeManifest the way name/ + * description do; it's a structural field of the document, added here + * directly instead. Not locale-suffixed like the attribute-manifest + * fields — option values are stored as one already-resolved string per + * variant (see ProductIndexer::translatedName()), not per-locale. * * @return array */ - private function searchableFields(string $locale, string $defaultLocale): array + private function searchableFields(): array { $handles = AttributeManifest::getSearchableAttributes(Product::morphName()) ->pluck('handle'); - $locales = array_unique([$locale, $defaultLocale]); + $locales = Language::all()->pluck('code'); - return $handles + $attributeFields = $handles ->crossJoin($locales) - ->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}") + ->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}"); + + return $attributeFields + ->push('variants.options.value') ->values() ->all(); } diff --git a/src/Catalog/Services/ProductService.php b/src/Catalog/Services/ProductService.php index 616426e..10bb032 100644 --- a/src/Catalog/Services/ProductService.php +++ b/src/Catalog/Services/ProductService.php @@ -4,7 +4,6 @@ namespace Modules\Core\Catalog\Services; use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; use Illuminate\Pagination\LengthAwarePaginator; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\App; use Lunar\Base\AttributeManifest; use Lunar\FieldTypes\TranslatedText; @@ -12,6 +11,7 @@ use Lunar\Models\Product; use Modules\Core\Localization\Services\LanguageCache; use Modules\Core\Catalog\DTOs\ProductFilters; use Modules\Core\Catalog\Enums\ProductSort; +use Modules\Core\Catalog\Support\ProductFilterBuilder; /** * Storefront product listing/filtering AND single-product lookup, all reading directly @@ -27,6 +27,7 @@ class ProductService public function __construct( private readonly LanguageCache $languages, private readonly AttributeManifest $attributes, + private readonly ProductFilterBuilder $filterBuilder, ) {} /** @@ -37,7 +38,7 @@ class ProductService */ public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): LengthAwarePaginator { - $options = ['filter' => $this->buildFilter($filters)]; + $options = ['filter' => $this->filterBuilder->build($filters)]; if ($sort !== null) { $options['sort'] = [$sort->toMeilisearchSort()]; @@ -79,7 +80,7 @@ class ProductService */ public function facets(string $field, ?ProductFilters $filters = null): array { - return $this->rawFacets($field, $this->buildFilter($filters))['facetDistribution'][$field] ?? []; + return $this->rawFacets($field, $this->filterBuilder->build($filters))['facetDistribution'][$field] ?? []; } /** @@ -90,12 +91,17 @@ class ProductService * not `facetDistribution` — the right feature for a numeric field's range, * where `facets('price')` would otherwise return one entry per exact price. * + * $query defaults to '' (every product, same as list()'s own default text + * query) — pass the shopper's search text here too so a search page's own + * price slider spans only the products that search actually matched, + * rather than the whole catalog's price range. + * * @return array{min: ?float, max: ?float} null/null if no product matches */ - public function priceRange(?ProductFilters $filters = null): array + public function priceRange(?ProductFilters $filters = null, string $query = ''): array { - $filter = $this->buildFilter($filters, exclude: ['price']); - $stats = $this->rawFacets('price', $filter)['facetStats']['price'] ?? null; + $filter = $this->filterBuilder->build($filters, exclude: ['price']); + $stats = $this->rawFacets('price', $filter, $query)['facetStats']['price'] ?? null; return [ 'min' => $stats['min'] ?? null, @@ -103,9 +109,9 @@ class ProductService ]; } - private function rawFacets(string $field, ?string $filter): array + private function rawFacets(string $field, ?string $filter, string $query = ''): array { - return Product::search('') + return Product::search($query) ->options([ 'filter' => $filter, 'facets' => [$field], @@ -204,28 +210,4 @@ class ProductService return collect($rawResponse['hits'] ?? [])->values()->all(); } - /** - * @param array $exclude filter - * fields to leave out even if set on $filters — e.g. priceRange() excludes - * 'price' so a price slider's own bounds don't shrink to whatever range is - * already selected on it. - */ - private function buildFilter(?ProductFilters $filters, array $exclude = []): ?string - { - if ($filters === null) { - return null; - } - - $clauses = Collection::make([ - 'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null, - 'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null, - 'price' => Collection::make([ - $filters->minPrice !== null ? "price >= {$filters->minPrice}" : null, - $filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null, - ])->filter()->join(' AND ') ?: null, - 'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null, - ])->except($exclude)->filter(); - - return $clauses->isEmpty() ? null : $clauses->join(' AND '); - } } diff --git a/src/Catalog/Support/ProductFilterBuilder.php b/src/Catalog/Support/ProductFilterBuilder.php new file mode 100644 index 0000000..ba1cdf3 --- /dev/null +++ b/src/Catalog/Support/ProductFilterBuilder.php @@ -0,0 +1,40 @@ + $exclude + * filter fields to leave out even if set on $filters — e.g. + * ProductService::priceRange() excludes 'price' so a price slider's own + * bounds don't shrink to whatever range is already selected on it. + */ + public function build(?ProductFilters $filters, array $exclude = []): ?string + { + if ($filters === null) { + return null; + } + + $clauses = Collection::make([ + 'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null, + 'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null, + 'price' => Collection::make([ + $filters->minPrice !== null ? "price >= {$filters->minPrice}" : null, + $filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null, + ])->filter()->join(' AND ') ?: null, + 'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null, + ])->except($exclude)->filter(); + + return $clauses->isEmpty() ? null : $clauses->join(' AND '); + } +} diff --git a/src/Command/TuneProductSearchCommand.php b/src/Command/TuneProductSearchCommand.php new file mode 100644 index 0000000..e0687e1 --- /dev/null +++ b/src/Command/TuneProductSearchCommand.php @@ -0,0 +1,67 @@ +createMeilisearchDriver(); + + $index = $engine->getIndex((new Product)->searchableAs()); + + $this->components->info('Updating typo tolerance for product search...'); + + $task = $index->updateTypoTolerance([ + 'minWordSizeForTypos' => [ + 'oneTypo' => 8, + 'twoTypos' => 12, + ], + ]); + + $engine->waitForTask($task['taskUid']); + + $this->components->info('Disabling prefix search for product search...'); + + $task = $index->updatePrefixSearch('disabled'); + + $engine->waitForTask($task['taskUid']); + + $this->components->info('Product search index tuned.'); + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 0574c40..2756ca1 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -10,6 +10,7 @@ use Modules\Core\Command\ExportCommand; use Modules\Core\Command\ImportCommand; use Modules\Core\Command\InstallLunarCommand; use Modules\Core\Command\MigrateImportCommand; +use Modules\Core\Command\TuneProductSearchCommand; class CoreServiceProvider extends ServiceProvider { @@ -35,7 +36,7 @@ class CoreServiceProvider extends ServiceProvider ], 'core-assets'); if ($this->app->runningInConsole()) { - $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class]); + $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class]); //Overriding lunar:install $this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));