Feature: Adding Facets, Updating Indexers
This commit is contained in:
@@ -21,6 +21,8 @@ nowhere near enough for a storefront category page or a nav tree.
|
|||||||
| `collection_group_id` | `$model->collection_group_id` | Filterable. Mirrors `Collection::scopeInGroup()`. |
|
| `collection_group_id` | `$model->collection_group_id` | Filterable. Mirrors `Collection::scopeInGroup()`. |
|
||||||
| `slugs` | `$model->urls->pluck('slug')` | Filterable. Every locale's `Url::slug`, so `getBySlug()` resolves purely from the index. |
|
| `slugs` | `$model->urls->pluck('slug')` | Filterable. Every locale's `Url::slug`, so `getBySlug()` resolves purely from the index. |
|
||||||
| `thumbnail` | `$model->getThumbnailImage()` | Display only. `null` if the collection has no thumbnail image. |
|
| `thumbnail` | `$model->getThumbnailImage()` | Display only. `null` if the collection has no thumbnail image. |
|
||||||
|
| `ancestors` | `$model->ancestors` | Display only. Array of `{id, name}`, ordered root-first — a breadcrumb (`Home > Apparel > Keychains`) can render directly from a single `getById()`/`getBySlug()` call, no extra queries. Empty array for a top-level collection. |
|
||||||
|
| `product_count` | Queried from the *product* Meilisearch index at collection-index time | Display only. How many products are in this collection **or any of its descendants** — matches what `ProductService::list(ProductFilters(collectionId: ...))` would return, not just direct assignment. Computed via `Product::search('')->options(['filter' => "collection_ids = \"{id}\""])`, so it depends on the product index already being current — reindex products *before* collections (see "Gotchas" below). |
|
||||||
|
|
||||||
`name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale
|
`name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale
|
||||||
by Lunar's base indexer and resolved by `CollectionService` exactly like
|
by Lunar's base indexer and resolved by `CollectionService` exactly like
|
||||||
@@ -87,6 +89,19 @@ lunar:meilisearch:setup` re-syncs index settings, and existing documents need
|
|||||||
the queue worker also needs restarting after deploying changes to the indexer class —
|
the queue worker also needs restarting after deploying changes to the indexer class —
|
||||||
see `docs/lunar.md` "Gotchas".
|
see `docs/lunar.md` "Gotchas".
|
||||||
|
|
||||||
|
**`product_count` needs the product index reindexed first.** `config/lunar/search.php`'s
|
||||||
|
`indexers` array is typically ordered `Collection` before `Product`, so a plain
|
||||||
|
`lunar:search:index --refresh` computes `product_count` against whatever the product
|
||||||
|
index held *before* this run — stale if products changed too. `lunar:search:index`
|
||||||
|
takes an explicit model list as its argument (`--ignore` restricts it to only those),
|
||||||
|
so reindex products first, then collections, when both need a fresh `--refresh` in the
|
||||||
|
same deploy:
|
||||||
|
|
||||||
|
```
|
||||||
|
php artisan lunar:search:index "Lunar\Models\Product" --ignore --refresh
|
||||||
|
php artisan lunar:search:index "Lunar\Models\Collection" --ignore --refresh
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## When to still use Eloquent directly
|
## When to still use Eloquent directly
|
||||||
|
|||||||
+34
-2
@@ -33,9 +33,9 @@ $service = app(ProductService::class);
|
|||||||
// "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator.
|
// "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator.
|
||||||
$products = $service->list(perPage: 24, page: 1);
|
$products = $service->list(perPage: 24, page: 1);
|
||||||
|
|
||||||
// Filter by collection, brand, and/or price range
|
// Filter by collection, brand, price range, and/or stock
|
||||||
$products = $service->list(
|
$products = $service->list(
|
||||||
filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0),
|
filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true),
|
||||||
perPage: 24,
|
perPage: 24,
|
||||||
page: 1,
|
page: 1,
|
||||||
);
|
);
|
||||||
@@ -56,10 +56,41 @@ $product = $service->getById(367); // array, or null if not found
|
|||||||
|
|
||||||
// Single product, by URL slug (any locale — slugs are indexed across all languages)
|
// Single product, by URL slug (any locale — slugs are indexed across all languages)
|
||||||
$product = $service->getBySlug('erotika-mprelok'); // array, or null if not found
|
$product = $service->getBySlug('erotika-mprelok'); // array, or null if not found
|
||||||
|
|
||||||
|
// Facet counts for a sidebar — value => matching product count, scoped to whatever
|
||||||
|
// $filters is passed. Does NOT exclude the faceted field itself from $filters — see
|
||||||
|
// facets()'s docblock for why, and how to build a standard "every option's count,
|
||||||
|
// unaffected by that option's own currently-selected value" sidebar.
|
||||||
|
$brandCounts = $service->facets('brand', filters: new ProductFilters(collectionId: 17));
|
||||||
|
// ['3Dealer.gr - 3D printed creations' => 48, 'Kraniou Topos - 3D printed creations' => 135]
|
||||||
|
|
||||||
|
// Min/max price across matching products, for sizing a price-range slider.
|
||||||
|
// minPrice/maxPrice are ALWAYS excluded from the filter driving this (unlike
|
||||||
|
// facets(), which doesn't auto-exclude) — the slider's own bounds shouldn't shrink
|
||||||
|
// to whatever range is currently selected on it. Other filters (collectionId,
|
||||||
|
// brand, inStockOnly) still apply normally.
|
||||||
|
$range = $service->priceRange(new ProductFilters(collectionId: 17));
|
||||||
|
// ['min' => 0.0, 'max' => 120.0]
|
||||||
```
|
```
|
||||||
|
|
||||||
All `ProductFilters` fields are optional; only the ones set are added to the Meilisearch query.
|
All `ProductFilters` fields are optional; only the ones set are added to the Meilisearch query.
|
||||||
|
|
||||||
|
`facets()` only makes sense on discrete-value filterable fields (`brand`, `in_stock`) — a numeric
|
||||||
|
field like `price` would return one "facet" per exact price, not a usable range bucket. Use
|
||||||
|
`priceRange()` for `price` instead, which reads Meilisearch's `facetStats` (min/max), a different
|
||||||
|
feature from `facetDistribution`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stock goes stale between orders
|
||||||
|
|
||||||
|
`in_stock` reflects `ProductVariant::stock`/`purchasable` as of the **last reindex**, not live
|
||||||
|
inventory. Nothing in this codebase currently reindexes a product when an order decrements its
|
||||||
|
stock — that's a cart/checkout concern, not something `ProductIndexer` can solve on its own (see
|
||||||
|
`Modules\Core\Catalog\Observers\ProductOptionReindexObserver` for the equivalent pattern once an
|
||||||
|
order → stock → reindex pipeline exists to hook into). Until then, `in_stock`/`product_count` can
|
||||||
|
drift from the database the same way every other indexed field already can between writes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fields this depends on: `Modules\Core\Catalog\Services\ProductIndexer`
|
## Fields this depends on: `Modules\Core\Catalog\Services\ProductIndexer`
|
||||||
@@ -81,6 +112,7 @@ needs, listing and detail alike:
|
|||||||
| `media` | `$product->media` | Full gallery (id/url/thumb per image), not just the single thumbnail Lunar's base indexer sends. |
|
| `media` | `$product->media` | Full gallery (id/url/thumb per image), not just the single thumbnail Lunar's base indexer sends. |
|
||||||
| `variants` | `$product->variants` | Per variant: `id`, `sku`, `stock`, `purchasable`, `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (variant-specific images). |
|
| `variants` | `$product->variants` | Per variant: `id`, `sku`, `stock`, `purchasable`, `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (variant-specific images). |
|
||||||
| `reviews` | `Modules\Core\Review\Models\ProductReview` | `{items, count, average_rating}` — see "Reviews" below. |
|
| `reviews` | `Modules\Core\Review\Models\ProductReview` | `{items, count, average_rating}` — see "Reviews" below. |
|
||||||
|
| `in_stock` | `$model->variants` | Filterable boolean. `true` if ANY variant currently passes `ProductVariant::canBeFulfilledAtQuantity(1)` — Lunar's own purchasability rule (`purchasable === 'always'` ignores stock entirely; `in_stock` checks `stock` alone; anything else checks `stock + backorder`). Only as fresh as the last reindex — see "Stock goes stale" below. |
|
||||||
|
|
||||||
`name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale — see
|
`name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale — see
|
||||||
"Locale resolution" below for how `ProductService` resolves them down to one value per request.
|
"Locale resolution" below for how `ProductService` resolves them down to one value per request.
|
||||||
|
|||||||
@@ -23,5 +23,6 @@ class ProductFilters
|
|||||||
public readonly ?string $brand = null,
|
public readonly ?string $brand = null,
|
||||||
public readonly ?float $minPrice = null,
|
public readonly ?float $minPrice = null,
|
||||||
public readonly ?float $maxPrice = null,
|
public readonly ?float $maxPrice = null,
|
||||||
|
public readonly bool $inStockOnly = false,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Modules\Core\Catalog\Services;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Lunar\Models\Collection;
|
use Lunar\Models\Collection;
|
||||||
|
use Lunar\Models\Product;
|
||||||
use Lunar\Search\CollectionIndexer as BaseCollectionIndexer;
|
use Lunar\Search\CollectionIndexer as BaseCollectionIndexer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +21,15 @@ use Lunar\Search\CollectionIndexer as BaseCollectionIndexer;
|
|||||||
* - slugs (filterable) — every locale's Url::slug, so getBySlug() resolves from the
|
* - slugs (filterable) — every locale's Url::slug, so getBySlug() resolves from the
|
||||||
* index directly, no database read
|
* index directly, no database read
|
||||||
* - thumbnail (display) — the collection's thumbnail image URL
|
* - thumbnail (display) — the collection's thumbnail image URL
|
||||||
|
* - ancestors (display) — [{id, name}, ...] ordered root-first, so a breadcrumb can
|
||||||
|
* render directly from a single indexed document with zero extra queries
|
||||||
|
* - product_count (display) — how many products are in this collection or any of
|
||||||
|
* its descendants, read from the *product* Meilisearch index at collection-index
|
||||||
|
* time (via `collection_ids`, see Modules\Core\Catalog\Services\ProductIndexer) —
|
||||||
|
* matches what ProductService::list(ProductFilters(collectionId: ...)) would
|
||||||
|
* return, not just direct assignment. Reflects the product index's state as of
|
||||||
|
* the last collection reindex, so re-run `lunar:search:index --refresh` after a
|
||||||
|
* product reindex if this needs to be current.
|
||||||
*
|
*
|
||||||
* New fields aren't filterable/sortable in Meilisearch until `php artisan
|
* New fields aren't filterable/sortable in Meilisearch until `php artisan
|
||||||
* lunar:meilisearch:setup` re-syncs index settings, and existing documents need
|
* lunar:meilisearch:setup` re-syncs index settings, and existing documents need
|
||||||
@@ -49,7 +59,7 @@ class CollectionIndexer extends BaseCollectionIndexer
|
|||||||
|
|
||||||
public function makeAllSearchableUsing(Builder $query): Builder
|
public function makeAllSearchableUsing(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return parent::makeAllSearchableUsing($query)->with(['urls', 'media']);
|
return parent::makeAllSearchableUsing($query)->with(['urls', 'media', 'ancestors']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toSearchableArray(Model $model): array
|
public function toSearchableArray(Model $model): array
|
||||||
@@ -63,6 +73,18 @@ class CollectionIndexer extends BaseCollectionIndexer
|
|||||||
$data['collection_group_id'] = $model->collection_group_id;
|
$data['collection_group_id'] = $model->collection_group_id;
|
||||||
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
|
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
|
||||||
$data['thumbnail'] = $model->getThumbnailImage() ?: null;
|
$data['thumbnail'] = $model->getThumbnailImage() ?: null;
|
||||||
|
$data['ancestors'] = $model->ancestors
|
||||||
|
->sortBy('_lft')
|
||||||
|
->map(fn ($ancestor) => [
|
||||||
|
'id' => $ancestor->id,
|
||||||
|
'name' => $ancestor->translateAttribute('name'),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
$data['product_count'] = Product::search('')
|
||||||
|
->options(['filter' => "collection_ids = \"{$model->id}\""])
|
||||||
|
->paginateRaw(perPage: 1, page: 1)
|
||||||
|
->total();
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
|||||||
* - channel_ids (filterable) — Lunar's base indexer only indexes "status" as
|
* - channel_ids (filterable) — Lunar's base indexer only indexes "status" as
|
||||||
* filterable, not channel assignment, so search results can't otherwise be
|
* filterable, not channel assignment, so search results can't otherwise be
|
||||||
* scoped to products actually assigned+enabled on the current sales channel
|
* scoped to products actually assigned+enabled on the current sales channel
|
||||||
|
* - in_stock (filterable) — true if ANY variant can currently be purchased at
|
||||||
|
* quantity 1, via ProductVariant::canBeFulfilledAtQuantity() (Lunar's own
|
||||||
|
* purchasability rule: `purchasable === 'always'` is always true regardless of
|
||||||
|
* stock, `in_stock` checks stock alone, anything else checks stock+backorder).
|
||||||
|
* Reflects stock as of the last reindex only — nothing currently reindexes a
|
||||||
|
* product when an order decrements its stock (see docs/product-listing.md).
|
||||||
*
|
*
|
||||||
* A review is created/edited independently of its product (Modules\Core\Providers\
|
* A review is created/edited independently of its product (Modules\Core\Providers\
|
||||||
* ReviewServiceProvider re-indexes the product on review create/update/delete), so
|
* ReviewServiceProvider re-indexes the product on review create/update/delete), so
|
||||||
@@ -60,6 +66,7 @@ class ProductIndexer extends BaseProductIndexer
|
|||||||
'price',
|
'price',
|
||||||
'slugs',
|
'slugs',
|
||||||
'channel_ids',
|
'channel_ids',
|
||||||
|
'in_stock',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +123,9 @@ class ProductIndexer extends BaseProductIndexer
|
|||||||
->wherePivot('enabled', true)
|
->wherePivot('enabled', true)
|
||||||
->pluck('lunar_channels.id')
|
->pluck('lunar_channels.id')
|
||||||
->toArray();
|
->toArray();
|
||||||
|
$data['in_stock'] = $model->variants->contains(
|
||||||
|
fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1)
|
||||||
|
);
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,60 @@ class ProductService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Facet value counts for the given filter/field, scoped to the SAME filters
|
||||||
|
* `list()` would apply. Note this does NOT exclude `$field` itself from
|
||||||
|
* `$filters` — e.g. `facets('brand', new ProductFilters(brand: 'Acme'))` would
|
||||||
|
* scope the counts to only "Acme" already, collapsing every other brand's count
|
||||||
|
* to whatever remains under that filter. For a standard "faceted sidebar" (every
|
||||||
|
* brand's count reflecting collection/price/stock filters but NOT the brand
|
||||||
|
* filter itself), build a `$filters` that omits the field being faceted on and
|
||||||
|
* apply that field's own filter separately in the UI/query layer.
|
||||||
|
*
|
||||||
|
* `$field` must be one of ProductIndexer's filterable fields; only discrete-value
|
||||||
|
* fields make sense here (`brand`, `in_stock`) — a numeric field like `price`
|
||||||
|
* would return one "facet" per exact price, not a usable range bucket. Use
|
||||||
|
* `priceRange()` for `price` instead.
|
||||||
|
*
|
||||||
|
* @return array<string, int> facet value => matching product count
|
||||||
|
*/
|
||||||
|
public function facets(string $field, ?ProductFilters $filters = null): array
|
||||||
|
{
|
||||||
|
return $this->rawFacets($field, $this->buildFilter($filters))['facetDistribution'][$field] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The min/max `price` across products matching the given filters (minus
|
||||||
|
* `minPrice`/`maxPrice` themselves, same "scoped but not self-collapsing"
|
||||||
|
* reasoning as `facets()` — a price slider's own bounds shouldn't shrink to
|
||||||
|
* whatever range is currently selected). Backed by Meilisearch's `facetStats`,
|
||||||
|
* not `facetDistribution` — the right feature for a numeric field's range,
|
||||||
|
* where `facets('price')` would otherwise return one entry per exact price.
|
||||||
|
*
|
||||||
|
* @return array{min: ?float, max: ?float} null/null if no product matches
|
||||||
|
*/
|
||||||
|
public function priceRange(?ProductFilters $filters = null): array
|
||||||
|
{
|
||||||
|
$filter = $this->buildFilter($filters, exclude: ['price']);
|
||||||
|
$stats = $this->rawFacets('price', $filter)['facetStats']['price'] ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'min' => $stats['min'] ?? null,
|
||||||
|
'max' => $stats['max'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rawFacets(string $field, ?string $filter): array
|
||||||
|
{
|
||||||
|
return Product::search('')
|
||||||
|
->options([
|
||||||
|
'filter' => $filter,
|
||||||
|
'facets' => [$field],
|
||||||
|
'hitsPerPage' => 0,
|
||||||
|
])
|
||||||
|
->raw();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up a single product by its URL slug (any locale - slugs are indexed across
|
* Look up a single product by its URL slug (any locale - slugs are indexed across
|
||||||
* all languages, see Modules\Core\Catalog\Services\ProductIndexer). Returns the full
|
* all languages, see Modules\Core\Catalog\Services\ProductIndexer). Returns the full
|
||||||
@@ -150,18 +204,27 @@ class ProductService
|
|||||||
return collect($rawResponse['hits'] ?? [])->values()->all();
|
return collect($rawResponse['hits'] ?? [])->values()->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildFilter(?ProductFilters $filters): ?string
|
/**
|
||||||
|
* @param array<int, 'collectionId'|'brand'|'price'|'inStockOnly'> $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) {
|
if ($filters === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$clauses = Collection::make([
|
$clauses = Collection::make([
|
||||||
$filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null,
|
'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null,
|
||||||
$filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
|
'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
|
||||||
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
|
'price' => Collection::make([
|
||||||
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
|
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
|
||||||
])->filter();
|
$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 ');
|
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user