diff --git a/src/Command/WipeCatalogCommand.php b/src/Command/WipeCatalogCommand.php index ec1d572..b7bc8cc 100644 --- a/src/Command/WipeCatalogCommand.php +++ b/src/Command/WipeCatalogCommand.php @@ -124,30 +124,49 @@ class WipeCatalogCommand extends Command * same way regardless. media_variant and product_option_value_ * product_variant DO cascade at the DB level (see their own * migrations), so deleting the variant itself is enough for those two. + * + * Deliberately NOT chunkById() — that re-queries "id > lastSeenId" + * every iteration, but deleting rows inside the loop shrinks the + * table out from under it: any product whose id fell in a range + * chunkById() had already stepped past could be silently skipped and + * never actually deleted at all. Caught in practice — the first real + * run of this command left orphaned Media rows (Spatie's own + * deleteAllMedia(), fired from Product's `deleting` event, never ran + * for the skipped products) whose 'image' ImportMapping rows then + * caused a LATER Shopify re-import to silently reuse those now- + * orphaned Media objects instead of importing fresh ones — see + * MigrateImport\Shopify\ShopifyExportImporter::resolveOrImportImage()'s + * own docblock for that half of the same incident. Always re-querying + * the first N remaining rows (never advancing an id cursor) guarantees + * every product is actually visited exactly once, however many are + * deleted out from under the query as it goes. */ private function wipe(): void { - ImportMapping::where('source_type', 'product')->delete(); - ImportMapping::where('source_type', 'variant')->delete(); + ImportMapping::whereIn('source_type', ['product', 'variant', 'image'])->delete(); - // Model-by-model, not a bulk query — see class docblock on why - // this must go through Eloquent for Spatie's media cleanup to - // fire on both Product and ProductVariant. - Product::with(['variants', 'associations', 'inverseAssociations']) - ->chunkById(100, function ($products) { - foreach ($products as $product) { - $product->associations()->delete(); - $product->inverseAssociations()->delete(); - $product->productOptions()->detach(); + while (true) { + $products = Product::with(['variants', 'associations', 'inverseAssociations']) + ->limit(100) + ->get(); - foreach ($product->variants as $variant) { - $variant->prices()->delete(); - $variant->delete(); - } + if ($products->isEmpty()) { + break; + } - $product->delete(); + foreach ($products as $product) { + $product->associations()->delete(); + $product->inverseAssociations()->delete(); + $product->productOptions()->detach(); + + foreach ($product->variants as $variant) { + $variant->prices()->delete(); + $variant->delete(); } - }); + + $product->delete(); + } + } Product::removeAllFromSearch(); } diff --git a/src/MigrateImport/Shopify/ShopifyExportImporter.php b/src/MigrateImport/Shopify/ShopifyExportImporter.php index 5032233..ea5001f 100644 --- a/src/MigrateImport/Shopify/ShopifyExportImporter.php +++ b/src/MigrateImport/Shopify/ShopifyExportImporter.php @@ -245,7 +245,23 @@ class ShopifyExportImporter implements Importer $existing = ImportMapping::resolve(self::SOURCE, 'image', $externalId); - if ($existing instanceof Media) { + // ImportMapping is a durable record of "we already imported this," + // but the Media row it points at can go stale — e.g. Command\ + // WipeCatalogCommand deletes every Product (media included, via + // Spatie's own model-delete cleanup) without knowing this mapping + // exists, since the mapping ISN'T scoped to a single Product to + // clean up alongside it. Re-running an import afterward used to + // trust the cached Media object unconditionally — it still existed + // as a PHP object even though its underlying row (and file) were + // long gone, so every re-imported product silently got zero + // media, no error, no warning. Falls through to a fresh import + // whenever the mapping doesn't resolve to a real, still-attached + // Media row. + if ($existing instanceof Media + && Media::whereKey($existing->getKey())->exists() + && $existing->model_type === $product->getMorphClass() + && (int) $existing->model_id === $product->id + ) { return $existing; }