From dbbe7931b9bf361e464bb471e4d8ed6e288c1458 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Mon, 16 Mar 2026 06:51:07 +0000 Subject: [PATCH] Ensure any metafield data removed on Shopify is removed in Statamic --- src/Jobs/ImportSingleProductJob.php | 41 ++++---- tests/Unit/ImportSingleProductJobTest.php | 111 ++++++++++++++++++++++ 2 files changed, 134 insertions(+), 18 deletions(-) diff --git a/src/Jobs/ImportSingleProductJob.php b/src/Jobs/ImportSingleProductJob.php index 5754379..a87f4a9 100644 --- a/src/Jobs/ImportSingleProductJob.php +++ b/src/Jobs/ImportSingleProductJob.php @@ -237,15 +237,7 @@ public function handle() // meta fields try { - $metafields = collect(Arr::get($this->data, 'metafields.edges', []))->map(fn ($metafield) => $metafield['node'] ?? [])->filter()->all(); - - if ($metafields) { - $metafields = $this->parseMetafields($metafields, 'product'); - - if ($metafields) { - $entry->merge($metafields); - } - } + $this->syncMetafields($entry, Arr::get($this->data, 'metafields.edges', []), 'product'); } catch (\Throwable $e) { Log::error('Could not retrieve metafields for product '.$this->data['id']); Log::error($e->getMessage()); @@ -619,15 +611,7 @@ private function importVariants(array $returnedVariants, string $product_slug, G $entry->merge($data); try { - $metafields = collect(Arr::get($variant, 'metafields.edges', []))->map(fn ($metafield) => $metafield['node'] ?? [])->filter()->all(); - - if ($metafields) { - $metafields = $this->parseMetafields($metafields, 'product-variant'); - - if ($metafields) { - $entry->merge($metafields); - } - } + $this->syncMetafields($entry, Arr::get($variant, 'metafields.edges', []), 'product-variant'); } catch (\Throwable $e) { Log::error('Could not retrieve metafields for variant '.$this->data['id']); } @@ -717,6 +701,27 @@ public function failed(Throwable $exception): void ProductImportFailed::dispatch($this->productId, $this->storeHandle, $exception); } + /** + * Sync metafields onto an entry, clearing any keys that were previously set + * but are no longer returned by Shopify (i.e. the metafield was deleted). + */ + private function syncMetafields(\Statamic\Contracts\Entries\Entry $entry, array $edges, string $context): void + { + $raw = collect($edges)->map(fn ($m) => $m['node'] ?? [])->filter()->all(); + $parsed = $this->parseMetafields($raw, $context); + + $previousKeys = $entry->get('shopify_metafield_keys', []); + foreach (array_diff($previousKeys, array_keys($parsed)) as $staleKey) { + $entry->set($staleKey, null); + } + + if ($parsed) { + $entry->merge($parsed); + } + + $entry->set('shopify_metafield_keys', array_keys($parsed)); + } + /** * Update the purchase history for this item */ diff --git a/tests/Unit/ImportSingleProductJobTest.php b/tests/Unit/ImportSingleProductJobTest.php index 8c6f584..36fcd1c 100644 --- a/tests/Unit/ImportSingleProductJobTest.php +++ b/tests/Unit/ImportSingleProductJobTest.php @@ -241,6 +241,117 @@ public function updates_changed_handle() $this->assertSame($entry->slug(), 'product-new'); } + #[Test] + public function clears_product_metafields_removed_in_shopify() + { + Facades\Collection::make(config('shopify.collection_handle', 'products'))->save(); + Facades\Taxonomy::make()->handle('collections')->save(); + Facades\Taxonomy::make()->handle('tags')->save(); + Facades\Taxonomy::make()->handle('type')->save(); + Facades\Taxonomy::make()->handle('vendor')->save(); + + $jsonWithMetafield = $this->getProductJson(); + $jsonWithoutMetafield = str_replace( + '"edges": [ + { + "node": { + "id" : 1, + "key": "some_metafield", + "value": "this is a value" + } + } + ]', + '"edges": []', + $jsonWithMetafield + ); + + $this->mock(Graphql::class, function (MockInterface $mock) use ($jsonWithMetafield, $jsonWithoutMetafield) { + $mock + ->shouldReceive('query') + ->andReturn( + new HttpResponse(status: 200, body: $jsonWithMetafield), + new HttpResponse(status: 200, body: $jsonWithoutMetafield) + ); + }); + + Jobs\ImportSingleProductJob::dispatch(1072481042); + + $entry = Facades\Entry::whereCollection(config('shopify.collection_handle', 'products'))->first(); + $this->assertSame($entry->get('some_metafield'), 'this is a value'); + + Jobs\ImportSingleProductJob::dispatch(1072481042); + + $entry = Facades\Entry::whereCollection(config('shopify.collection_handle', 'products'))->first(); + $this->assertNull($entry->get('some_metafield')); + $this->assertSame([], $entry->get('shopify_metafield_keys')); + } + + #[Test] + public function clears_variant_metafields_removed_in_shopify() + { + Facades\Collection::make(config('shopify.collection_handle', 'products'))->save(); + Facades\Taxonomy::make()->handle('collections')->save(); + Facades\Taxonomy::make()->handle('tags')->save(); + Facades\Taxonomy::make()->handle('type')->save(); + Facades\Taxonomy::make()->handle('vendor')->save(); + + $jsonWithVariantMetafield = str_replace( + '"metafields": { + "edges": [] + }', + '"metafields": { + "edges": [ + { + "node": { + "id": 2, + "key": "variant_metafield", + "value": "variant value" + } + } + ] + }', + $this->getProductJson() + ); + + $jsonWithoutVariantMetafield = str_replace( + '"metafields": { + "edges": [ + { + "node": { + "id": 2, + "key": "variant_metafield", + "value": "variant value" + } + } + ] + }', + '"metafields": { + "edges": [] + }', + $jsonWithVariantMetafield + ); + + $this->mock(Graphql::class, function (MockInterface $mock) use ($jsonWithVariantMetafield, $jsonWithoutVariantMetafield) { + $mock + ->shouldReceive('query') + ->andReturn( + new HttpResponse(status: 200, body: $jsonWithVariantMetafield), + new HttpResponse(status: 200, body: $jsonWithoutVariantMetafield) + ); + }); + + Jobs\ImportSingleProductJob::dispatch(1072481042); + + $variant = Facades\Entry::whereCollection('variants')->first(); + $this->assertSame($variant->get('variant_metafield'), 'variant value'); + + Jobs\ImportSingleProductJob::dispatch(1072481042); + + $variant = Facades\Entry::whereCollection('variants')->first(); + $this->assertNull($variant->get('variant_metafield')); + $this->assertSame([], $variant->get('shopify_metafield_keys')); + } + private function getProductJson(): string { return '{