Feature: Adding Facets, Updating Indexers

This commit is contained in:
2026-08-27 23:09:33 +03:00
parent a2c3fd5457
commit 1c14fabf44
6 changed files with 152 additions and 9 deletions
+15
View File
@@ -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()`. |
| `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. |
| `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
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 —
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
+34 -2
View File
@@ -33,9 +33,9 @@ $service = app(ProductService::class);
// "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator.
$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(
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,
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)
$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.
`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`
@@ -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. |
| `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. |
| `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
"Locale resolution" below for how `ProductService` resolves them down to one value per request.
+1
View File
@@ -23,5 +23,6 @@ class ProductFilters
public readonly ?string $brand = null,
public readonly ?float $minPrice = null,
public readonly ?float $maxPrice = null,
public readonly bool $inStockOnly = false,
) {}
}
+23 -1
View File
@@ -5,6 +5,7 @@ namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Lunar\Models\Collection;
use Lunar\Models\Product;
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
* index directly, no database read
* - 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
* 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
{
return parent::makeAllSearchableUsing($query)->with(['urls', 'media']);
return parent::makeAllSearchableUsing($query)->with(['urls', 'media', 'ancestors']);
}
public function toSearchableArray(Model $model): array
@@ -63,6 +73,18 @@ class CollectionIndexer extends BaseCollectionIndexer
$data['collection_group_id'] = $model->collection_group_id;
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
$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;
}
+10
View File
@@ -37,6 +37,12 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* - channel_ids (filterable) — Lunar's base indexer only indexes "status" as
* filterable, not channel assignment, so search results can't otherwise be
* 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\
* ReviewServiceProvider re-indexes the product on review create/update/delete), so
@@ -60,6 +66,7 @@ class ProductIndexer extends BaseProductIndexer
'price',
'slugs',
'channel_ids',
'in_stock',
];
}
@@ -116,6 +123,9 @@ class ProductIndexer extends BaseProductIndexer
->wherePivot('enabled', true)
->pluck('lunar_channels.id')
->toArray();
$data['in_stock'] = $model->variants->contains(
fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1)
);
return $data;
}
+69 -6
View File
@@ -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
* all languages, see Modules\Core\Catalog\Services\ProductIndexer). Returns the full
@@ -150,18 +204,27 @@ class ProductService
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) {
return null;
}
$clauses = Collection::make([
$filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null,
$filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
])->filter();
'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 ');
}