Feat: Updates to Product Indexer, Shopify Importer, Adding cascade delete on reviews

This commit is contained in:
2026-09-03 12:26:00 +03:00
parent 448da869a9
commit f43f72f633
4 changed files with 123 additions and 10 deletions
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* product_reviews.product_id's original foreign key (2026_07_10_000001) had
* no ON DELETE clause, so deleting a Product with reviews throws a
* constraint violation instead of the review rows going with it — unlike
* every other Product-dependent table (variants, media, etc.), which does
* cascade. A review is dependent, disposable data, not something worth
* blocking a product deletion over.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('product_reviews', function (Blueprint $table) {
$table->dropForeign(['product_id']);
});
Schema::table('product_reviews', function (Blueprint $table) {
$table->foreign('product_id')
->references('id')
->on(config('lunar.database.table_prefix').'products')
->cascadeOnDelete();
});
}
public function down(): void
{
Schema::table('product_reviews', function (Blueprint $table) {
$table->dropForeign(['product_id']);
});
Schema::table('product_reviews', function (Blueprint $table) {
$table->foreign('product_id')
->references('id')
->on(config('lunar.database.table_prefix').'products');
});
}
};
+2 -1
View File
@@ -135,11 +135,12 @@ needs, listing and detail alike:
| `collections` | `$product->collections` | Array of `{id, name}` — directly assigned collections only, `name` is the translated collection name. Not filterable — see `collection_ids`. |
| `collection_ids` | `$product->collections` + `->ancestors` | Filterable. Flat array of every directly-assigned collection's id, unioned with all of its ancestors' ids. `ProductFilters(collectionId: ...)` filters against this field, not `collections`, since products are typically attached only to leaf collections — a plain direct-match filter would never return anything for a parent/root category page. |
| `slugs` | `$product->urls->pluck('slug')` | Filterable. Every locale's `Url::slug` for the product, so `getBySlug()` resolves purely from the index — no database read. |
| `skus` | `$product->variants->pluck('sku')` | Filterable. Every variant's `sku`, deduplicated, empty ones dropped. Same "resolve from the index alone" reasoning as `slugs`, for a future SKU-based lookup/filter. |
| `price` | Cheapest variant's base price | Filterable. Float in major units (e.g. `19.99`, not `1999`). Base price only — no customer group, default currency (`Currency::getDefault()`) only. `null` if the product has no priced variant yet, so it's excluded from range filters rather than treated as free. |
| `brand` | Already indexed by Lunar's base indexer | Newly marked **filterable** — it existed in the document already, just wasn't usable in a `filter` clause. |
| `tags` | `$product->tags->pluck('value')` | Display only. |
| `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`, `gtin`, `mpn`, `ean`, `stock`, `backorder`, `unit_quantity`, `purchasable`, `shippable`, `tax_ref`, `dimensions` (`length`/`width`/`height`/`weight`/`volume`, each `{value, unit}`), `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (the variant's own images — `ProductVariant::images()`, a separate pivot from the product's own gallery above, populated by `ShopifyExportImporter` from Shopify's `Variant Image` CSV column). |
| `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. |
+23 -1
View File
@@ -27,8 +27,14 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* - slugs (every locale's Url::slug for the product, filterable) — lets
* ProductService::getBySlug() resolve a product from the index directly, with
* no database read at all
* - skus (every variant's sku, deduplicated, filterable) — same "resolve from the
* index alone" reasoning as slugs, for a future SKU-based lookup/filter
* - price (cheapest variant, filterable) and full per-variant pricing
* - variants: sku, stock, purchasable, option values, prices, media
* - variants: sku, gtin, mpn, ean, stock, backorder, unit_quantity, purchasable,
* shippable, tax_ref, dimensions (length/width/height/weight/volume, each
* {value, unit}), option values, prices, media — the variant's own images
* (ProductVariant::images(), separate from the product's gallery below), not
* the product's own media repeated per variant
* - the full media gallery (not just the single thumbnail Lunar's base indexer sends)
* - tags
* - reviews: {items: [...], count, average_rating} — items are public-safe fields
@@ -83,6 +89,7 @@ class ProductIndexer extends BaseProductIndexer
'channel_ids',
'in_stock',
'recommendations.id',
'skus',
];
}
@@ -126,6 +133,7 @@ class ProductIndexer extends BaseProductIndexer
->values()
->all();
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
$data['skus'] = $model->variants->pluck('sku')->filter()->unique()->values()->all();
$data['tags'] = $model->tags->pluck('value')->all();
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all();
$data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
@@ -161,8 +169,22 @@ class ProductIndexer extends BaseProductIndexer
return [
'id' => $variant->id,
'sku' => $variant->sku,
'gtin' => $variant->gtin,
'mpn' => $variant->mpn,
'ean' => $variant->ean,
'stock' => $variant->stock,
'backorder' => $variant->backorder,
'unit_quantity' => $variant->unit_quantity,
'purchasable' => $variant->purchasable,
'shippable' => $variant->shippable,
'tax_ref' => $variant->tax_ref,
'dimensions' => [
'length' => ['value' => $variant->length_value, 'unit' => $variant->length_unit],
'width' => ['value' => $variant->width_value, 'unit' => $variant->width_unit],
'height' => ['value' => $variant->height_value, 'unit' => $variant->height_unit],
'weight' => ['value' => $variant->weight_value, 'unit' => $variant->weight_unit],
'volume' => ['value' => $variant->volume_value, 'unit' => $variant->volume_unit],
],
'options' => $variant->values->map(fn ($value) => [
'option' => $this->translatedName($value->option->name),
'handle' => $value->option->handle,
@@ -25,6 +25,7 @@ use Modules\Core\MigrateImport\Shopify\Resolvers\ProductOptionResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\ProductTypeResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\TagResolver;
use Modules\Core\MigrateImport\Shopify\Resolvers\TaxClassResolver;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class ShopifyExportImporter implements Importer
{
@@ -113,7 +114,7 @@ class ShopifyExportImporter implements Importer
$options = $this->attachOptions($product, $row);
foreach ($group->variantRows as $index => $variantRow) {
$this->importVariant($product, $group->handle, $index, $variantRow, $taxClass, $currency, $options);
$this->importVariant($product, $group->handle, $index, $variantRow, $taxClass, $currency, $options, $imagesPath);
}
foreach ($group->imageRows as $index => $imageRow) {
@@ -151,6 +152,7 @@ class ShopifyExportImporter implements Importer
TaxClass $taxClass,
Currency $currency,
array $options,
string $imagesPath,
): void {
$externalId = "{$handle}#{$index}";
$existing = ImportMapping::resolve(self::SOURCE, 'variant', $externalId);
@@ -182,6 +184,26 @@ class ShopifyExportImporter implements Importer
: null;
$this->priceResolver->resolve($variant, $currency, $price, $comparePrice);
// Shopify's own "Variant Image" column — the one image a variant picker
// actually swaps to when that variant is selected — distinct from the
// product's full gallery (imageRows below). Often the same file as one
// of the product's own image rows, sometimes not yet imported at all
// (e.g. a variant-only image never listed as its own image row) — either
// way resolveOrImportImage() handles both via the same Image Src dedup
// key, so whichever of importVariant()/importImage() runs first for a
// given src does the actual import.
$variantImageSrc = trim((string) ($row['Variant Image'] ?? ''));
if ($variantImageSrc !== '') {
$media = $this->resolveOrImportImage($product, $handle, $variantImageSrc, 1, $imagesPath);
if ($media) {
$variant->images()->syncWithoutDetaching([
$media->id => ['primary' => true, 'position' => 1],
]);
}
}
}
private function importImage(
@@ -191,29 +213,54 @@ class ShopifyExportImporter implements Importer
array $row,
string $imagesPath,
): void {
$externalId = $row['Image Src'] ?: "{$handle}#image-{$index}";
$position = (int) ($row['Image Position'] ?? $index + 1);
if (ImportMapping::resolve(self::SOURCE, 'image', $externalId)) {
return;
$this->resolveOrImportImage($product, $handle, $row['Image Src'], $position, $imagesPath);
}
/**
* Resolves the Media already imported for $imageSrc (recorded under
* source_type 'image', keyed by Image Src — the same URL Shopify repeats
* across a product's own image rows and any variant's "Variant Image"
* column), importing it via AssetResolver if this is the first time this
* src has been seen. Shared by importImage() (product gallery) and
* importVariant() (variant-specific image) so the same physical file is
* never uploaded to Spatie MediaLibrary twice just because Shopify's flat
* CSV format repeats the URL on multiple rows.
*/
private function resolveOrImportImage(
Product $product,
string $handle,
string $imageSrc,
int $position,
string $imagesPath,
): ?Media {
$externalId = $imageSrc ?: "{$handle}#image-{$position}";
$existing = ImportMapping::resolve(self::SOURCE, 'image', $externalId);
if ($existing instanceof Media) {
return $existing;
}
$localFile = $this->findLocalFile($imagesPath, $row['Image Src']);
$localFile = $this->findLocalFile($imagesPath, $imageSrc);
if ($localFile === null) {
Log::warning('Shopify import: image file not found', [
'handle' => $handle,
'image_src' => $row['Image Src'],
'image_src' => $imageSrc,
]);
return;
return null;
}
$media = $this->assetResolver->resolve($product, $localFile, $position);
if ($media) {
ImportMapping::record(self::SOURCE, 'image', $externalId, $product);
ImportMapping::record(self::SOURCE, 'image', $externalId, $media);
}
return $media;
}
private function findLocalFile(string $imagesPath, string $imageSrc): ?string