Commit 424fbdd381b for woocommerce
commit 424fbdd381ba8bad765cf354e7812dfa3b04c821
Author: Ján Mikláš <neosinner@gmail.com>
Date: Fri Sep 4 15:08:18 2026 +0200
Match CSV updates by global unique ID (#67940)
* Match CSV updates by global unique ID
Let the CSV importer match an existing product or variation by its
normalized GTIN, UPC, EAN, or ISBN when a row carries neither an ID nor
a SKU, refusing matches of a different product type. Adds a prefix
index on wc_product_meta_lookup.global_unique_id so the lookup does not
scan the catalog, and orders the lookup by ID for deterministic matches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GU3W7KnYnsMo2dTVk65LPB
* Skip rows whose Global Unique ID normalizes to an empty value
A mapped GTIN such as ABC normalizes to an empty string, so the row was
treated as having no identifier and created a new product in update
mode. Check the mapped column like ID and SKU instead of the normalized
value.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MwHaUXmyrzWuBLiadR1T2C
* Refuse Global Unique ID matches whose product type differs from the row
Comparing only variation against non-variation still let a row typed
simple match a variable product, and get_product_object() then rewrote
the product type on save. A mapped but blank Type column defaults to
simple, so compare the resolved product type instead.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APxNvFVJ543DPf2ZYFhnpT
* Create products from blank Global Unique ID cells in update mode
The importer stored every mapped Global Unique ID cell, blank included,
so a CSV that maps the column without ID or SKU skipped rows with an
empty cell instead of creating them. Keep the raw cell, treat a blank
one as no identifier, and normalize at match time so a malformed value
such as ABC is still skipped rather than creating a product. The
formatting callback duplicated what WC_Product::set_global_unique_id()
already does on save.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APxNvFVJ543DPf2ZYFhnpT
* Cover Global Unique ID lookups for products sharing a code
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APxNvFVJ543DPf2ZYFhnpT
* Document the Global Unique ID matcher
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APxNvFVJ543DPf2ZYFhnpT
* Allow a variation row keyed only by a Global Unique ID to be created
The SKU requirement in can_create_variation() existed because a CSV ID cannot
be assigned to a new variation, so a SKU-less variation could never be matched
again and every re-import would duplicate it. Now that the importer matches
rows by Global Unique ID, that is no longer true: a non-blank one is a usable
re-import key, since it is unique per product.
Without this, adding the Global Unique ID to the guard in import() closed an
existing path: a row with a GTIN and no SKU column previously fell past the
guard and created the variation, and would now be refused instead.
The Global Unique ID is compared in the form it is stored in, because a cell
that strips to nothing is saved as blank and would leave the created variation
unmatchable. That stripping is now shared with match_product_id_by_global_unique_id()
so the two cannot drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JRJu9qq2KNYSj7TkoP32ah
* Treat a Global Unique ID that strips to nothing as no key at all
The guard that skips rows with no match compared the raw cell, while
match_product_id_by_global_unique_id() compared the stored form. A cell like
"n/a" is saved as a blank Global Unique ID, so the guard counted it as a key
that matched nothing and skipped the row, while a blank cell in the same
column still created the product.
That closed an existing path: with a GTIN column and no SKU column mapped,
such a row previously set neither 'id' nor 'sku' in the parsed data, fell past
the guard and created the product. Normalizing once, up front, restores it and
keeps the guard and the matcher judging the same value.
Both rewritten tests were checked against a reverted fix: the product row fails
without the normalization in import(), and the variation row fails without it in
can_create_variation().
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JRJu9qq2KNYSj7TkoP32ah
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/wooplug-7542-match-global-unique-id b/plugins/woocommerce/changelog/wooplug-7542-match-global-unique-id
new file mode 100644
index 00000000000..019dc5b1d55
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7542-match-global-unique-id
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Use GTIN, UPC, EAN, or ISBN as the match key for existing product and variation CSV updates when ID and SKU are absent, and let a variation row identified only by one create a new variation.
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index 54a94f5bd80..29f0be80edb 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -2045,7 +2045,8 @@ CREATE TABLE {$wpdb->prefix}wc_product_meta_lookup (
KEY `stock_quantity` (`stock_quantity`),
KEY `onsale` (`onsale`),
KEY min_max_price (`min_price`, `max_price`),
- KEY sku (sku(50))
+ KEY sku (sku(50)),
+ KEY global_unique_id (global_unique_id(50))
) $collate;
CREATE TABLE {$wpdb->prefix}wc_tax_rate_classes (
tax_rate_class_id bigint(20) unsigned NOT NULL auto_increment,
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
index c5ef13c9efd..92d1db73937 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
@@ -1420,6 +1420,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
posts.post_type IN ( 'product', 'product_variation' )
AND posts.post_status != 'trash'
AND lookup.global_unique_id = %s
+ ORDER BY posts.ID ASC
LIMIT 1
",
$global_unique_id
diff --git a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
index c44e4e38599..772cca78478 100644
--- a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
+++ b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
@@ -231,6 +231,61 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {
return apply_filters( 'woocommerce_product_import_get_product_object', $product, $data );
}
+ /**
+ * Reduce a Global Unique ID to the form it is stored in.
+ *
+ * Keep this in step with the stripping in WC_Product::set_global_unique_id(), so a row is
+ * matched and judged usable on the same value the product would be saved with.
+ *
+ * @param string $global_unique_id Global Unique ID as entered in the row.
+ * @return string Stored form of the Global Unique ID, or an empty string when nothing is left of it.
+ *
+ * @since 11.2.0
+ */
+ protected function normalize_global_unique_id( $global_unique_id ) {
+ return (string) preg_replace( '/[^0-9Xx\-]/', '', (string) $global_unique_id );
+ }
+
+ /**
+ * Find the product a row refers to by its Global Unique ID.
+ *
+ * A match whose product type disagrees with the row type is refused, because
+ * get_product_object() would otherwise convert the matched product to that type.
+ *
+ * @param string $global_unique_id Global Unique ID as entered in the row.
+ * @param string $type Row product type, or an empty string when the row has none.
+ * @return int|WP_Error Product ID, 0 when nothing matches, or WP_Error for a type mismatch.
+ *
+ * @since 11.2.0
+ */
+ protected function match_product_id_by_global_unique_id( $global_unique_id, $type ) {
+ $global_unique_id = $this->normalize_global_unique_id( $global_unique_id );
+
+ if ( '' === $global_unique_id ) {
+ return 0;
+ }
+
+ $product_id = (int) wc_get_product_id_by_global_unique_id( $global_unique_id );
+
+ if ( ! $product_id || '' === $type ) {
+ return $product_id;
+ }
+
+ if ( WC_Product_Factory::get_product_type( $product_id ) !== $type ) {
+ return new WP_Error(
+ 'woocommerce_product_importer_global_unique_id_type_mismatch',
+ esc_html__( 'The Global Unique ID matches a product of a different type.', 'woocommerce' ),
+ array(
+ 'id' => $product_id,
+ 'global_unique_id' => esc_attr( $global_unique_id ),
+ 'status' => 401,
+ )
+ );
+ }
+
+ return $product_id;
+ }
+
/**
* Process a single item and save.
*
diff --git a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
index f7ddebbdec4..813c5a4b9a2 100644
--- a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
+++ b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
@@ -1206,10 +1206,11 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
* @return string
*/
protected function get_row_id( $parsed_data ) {
- $id = isset( $parsed_data['id'] ) ? absint( $parsed_data['id'] ) : 0;
- $sku = isset( $parsed_data['sku'] ) ? esc_attr( $parsed_data['sku'] ) : '';
- $name = isset( $parsed_data['name'] ) ? esc_attr( $parsed_data['name'] ) : '';
- $row_data = array();
+ $id = isset( $parsed_data['id'] ) ? absint( $parsed_data['id'] ) : 0;
+ $sku = isset( $parsed_data['sku'] ) ? esc_attr( $parsed_data['sku'] ) : '';
+ $global_unique_id = isset( $parsed_data['global_unique_id'] ) ? esc_attr( $parsed_data['global_unique_id'] ) : '';
+ $name = isset( $parsed_data['name'] ) ? esc_attr( $parsed_data['name'] ) : '';
+ $row_data = array();
if ( $name ) {
$row_data[] = $name;
@@ -1222,6 +1223,10 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
/* translators: %s: product SKU */
$row_data[] = sprintf( __( 'SKU %s', 'woocommerce' ), $sku );
}
+ if ( $global_unique_id ) {
+ /* translators: %s: product GTIN, UPC, EAN, or ISBN */
+ $row_data[] = sprintf( __( 'GTIN, UPC, EAN, or ISBN %s', 'woocommerce' ), $global_unique_id );
+ }
return implode( ', ', $row_data );
}
@@ -1244,12 +1249,14 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
);
}
- // A CSV ID cannot be assigned to a new variation, so without a SKU the created variation
- // could never be matched again and every re-import would duplicate it.
- if ( empty( $parsed_data['sku'] ) ) {
+ // A CSV ID cannot be assigned to a new variation, so the row needs a key a later import can
+ // match it on; without one the created variation would be duplicated on every re-import.
+ // The Global Unique ID is compared in its stored form, since a cell that strips to nothing
+ // is saved as blank and would leave the variation unmatchable.
+ if ( empty( $parsed_data['sku'] ) && '' === $this->normalize_global_unique_id( $parsed_data['global_unique_id'] ?? '' ) ) {
return new WP_Error(
'woocommerce_product_importer_variation_missing_sku',
- esc_html__( 'A new variation cannot be created without a SKU.', 'woocommerce' )
+ esc_html__( 'A new variation cannot be created without a SKU or a GTIN, UPC, EAN, or ISBN.', 'woocommerce' )
);
}
@@ -1369,7 +1376,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
*
* Do not import products with IDs or SKUs that already exist if option
* update existing is false, and likewise, if updating products, do not
- * process rows which do not exist if an ID/SKU is provided.
+ * process rows which do not exist if an ID, SKU, or non-blank Global Unique ID is provided.
*
* @return array
*/
@@ -1388,10 +1395,28 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
foreach ( $this->parsed_data as $parsed_data_key => $parsed_data ) {
do_action( 'woocommerce_product_import_before_import', $parsed_data );
- $id = isset( $parsed_data['id'] ) ? absint( $parsed_data['id'] ) : 0;
- $sku = isset( $parsed_data['sku'] ) ? $parsed_data['sku'] : '';
- $id_exists = false;
- $sku_exists = false;
+ $id = isset( $parsed_data['id'] ) ? absint( $parsed_data['id'] ) : 0;
+ $sku = isset( $parsed_data['sku'] ) ? $parsed_data['sku'] : '';
+
+ // Compare the stored form throughout: a cell that strips to nothing is saved as a blank
+ // Global Unique ID, so it is no more of a match key than an empty cell is.
+ $global_unique_id = $this->normalize_global_unique_id( $parsed_data['global_unique_id'] ?? '' );
+ $id_exists = false;
+ $sku_exists = false;
+
+ if ( $update_existing && ! $id && ! $sku && '' !== $global_unique_id ) {
+ $id = $this->match_product_id_by_global_unique_id( $global_unique_id, $parsed_data['type'] ?? '' );
+
+ if ( is_wp_error( $id ) ) {
+ $id->add_data( array( 'row' => $this->get_row_id( $parsed_data ) ) );
+ $data['failed'][] = $id;
+ continue;
+ }
+
+ if ( $id ) {
+ $parsed_data['id'] = $id;
+ }
+ }
if ( $id ) {
$product = wc_get_product( $id );
@@ -1428,7 +1453,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
continue;
}
- if ( $update_existing && ( isset( $parsed_data['id'] ) || isset( $parsed_data['sku'] ) ) && ! $id_exists && ! $sku_exists ) {
+ if ( $update_existing && ( isset( $parsed_data['id'] ) || isset( $parsed_data['sku'] ) || '' !== $global_unique_id ) && ! $id_exists && ! $sku_exists ) {
$create_variation = false;
$refusal = null;
@@ -1461,9 +1486,10 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
'woocommerce_product_importer_error',
$refusal ? $refusal->get_error_message() : esc_html__( 'No matching product exists to update.', 'woocommerce' ),
array(
- 'id' => $id,
- 'sku' => esc_attr( $sku ),
- 'row' => $this->get_row_id( $parsed_data ),
+ 'id' => $id,
+ 'sku' => esc_attr( $sku ),
+ 'global_unique_id' => esc_attr( $global_unique_id ),
+ 'row' => $this->get_row_id( $parsed_data ),
)
);
continue;
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/product/data-store.php b/plugins/woocommerce/tests/legacy/unit-tests/product/data-store.php
index 8a70b7dccea..d9455d846d0 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/product/data-store.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/product/data-store.php
@@ -948,6 +948,40 @@ class WC_Tests_Product_Data_Store extends WC_Unit_Test_Case {
$this->assertContains( $product3->get_id(), $results );
}
+ /**
+ * @testdox Looking up a product by global_unique_id returns the lowest ID when several products share it.
+ *
+ * @return void
+ */
+ public function test_get_product_id_by_global_unique_id_prefers_the_lowest_id() {
+ global $wpdb;
+
+ $older_product = new WC_Product();
+ $older_product->set_regular_price( 42 );
+ $older_product->set_name( 'Older product' );
+ $older_product->save();
+
+ $newer_product = new WC_Product();
+ $newer_product->set_regular_price( 42 );
+ $newer_product->set_name( 'Newer product' );
+ $newer_product->set_global_unique_id( '5555555555555' );
+ $newer_product->save();
+
+ // Saving refuses a duplicate, so give the older product the same code behind the validation's back.
+ $updated = $wpdb->update(
+ $wpdb->prefix . 'wc_product_meta_lookup',
+ array( 'global_unique_id' => '5555555555555' ),
+ array( 'product_id' => $older_product->get_id() ),
+ array( '%s' ),
+ array( '%d' )
+ );
+ $this->assertSame( 1, $updated );
+
+ $data_store = WC_Data_Store::load( 'product' );
+
+ $this->assertSame( $older_product->get_id(), (int) $data_store->get_product_id_by_global_unique_id( '5555555555555' ) );
+ }
+
/**
* @testdox Test searching variations by global_unique_id.
*
diff --git a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
index 67a191edaf9..db69f47a519 100644
--- a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
+++ b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
@@ -247,6 +247,317 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
$this->assertEquals( 'A product with this SKU already exists.', $error->get_error_message() );
}
+ /**
+ * @testdox Existing products and variations can be updated by Global Unique ID when no ID or SKU is provided.
+ */
+ public function test_import_updates_existing_products_by_global_unique_id() {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_name( 'Original product name' );
+ $product->set_global_unique_id( '1234567890123' );
+ $product->save();
+
+ $parent = WC_Helper_Product::create_variation_product();
+ $variation_ids = $parent->get_children();
+ $variation = wc_get_product( reset( $variation_ids ) );
+ $variation->set_name( 'Original variation name' );
+ $variation->set_regular_price( '10' );
+ $variation->set_global_unique_id( '9781234567890' );
+ $variation->save();
+
+ $csv_file = trailingslashit( get_temp_dir() ) . 'import-products-by-global-unique-id.csv';
+ file_put_contents( $csv_file, "Type,GTIN,Name,Regular price\nsimple,123 456 789 0123,Updated product name,15\nvariation,978 123 456 7890,Updated variation name,25\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $importer = new WC_Product_CSV_Importer(
+ $csv_file,
+ array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'Type' => 'type',
+ 'GTIN' => 'global_unique_id',
+ 'Name' => 'name',
+ 'Regular price' => 'regular_price',
+ ),
+ )
+ );
+ $data = $importer->import();
+
+ wp_delete_file( $csv_file );
+
+ $this->assertSame( array( $product->get_id(), $variation->get_id() ), $data['updated'] );
+ $this->assertEmpty( $data['imported'] );
+ $this->assertEmpty( $data['imported_variations'] );
+ $this->assertEmpty( $data['failed'] );
+ $this->assertEmpty( $data['skipped'] );
+ $this->assertSame( 'Updated product name', wc_get_product( $product->get_id() )->get_name() );
+ $this->assertSame( '25', wc_get_product( $variation->get_id() )->get_regular_price() );
+
+ WC_Helper_Product::delete_product( $parent->get_id() );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
+ /**
+ * @testdox An unmatched Global Unique ID is skipped when updating existing products.
+ */
+ public function test_import_skips_unmatched_global_unique_id_when_updating_existing_products() {
+ $csv_file = trailingslashit( get_temp_dir() ) . 'import-unmatched-global-unique-id.csv';
+ file_put_contents( $csv_file, "GTIN,Name\n3210987654321,Product that should not be created\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $importer = new WC_Product_CSV_Importer(
+ $csv_file,
+ array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'GTIN' => 'global_unique_id',
+ 'Name' => 'name',
+ ),
+ )
+ );
+ $data = $importer->import();
+
+ wp_delete_file( $csv_file );
+
+ $this->assertEmpty( $data['updated'] );
+ $this->assertEmpty( $data['imported'] );
+ $this->assertEmpty( $data['imported_variations'] );
+ $this->assertEmpty( $data['failed'] );
+ $this->assertCount( 1, $data['skipped'] );
+ $this->assertSame( 'No matching product exists to update.', $data['skipped'][0]->get_error_message() );
+ $this->assertStringContainsString( '3210987654321', $data['skipped'][0]->get_error_data()['row'] );
+ $this->assertSame( 0, wc_get_product_id_by_global_unique_id( '3210987654321' ) );
+ }
+
+ /**
+ * @testdox A blank Global Unique ID cell creates a product while a matching one updates it when updating existing products.
+ */
+ public function test_import_creates_products_with_blank_global_unique_id_when_updating_existing_products() {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_name( 'Original product name' );
+ $product->set_global_unique_id( '2223334445556' );
+ $product->save();
+
+ $csv_file = trailingslashit( get_temp_dir() ) . 'import-blank-global-unique-id.csv';
+ file_put_contents( $csv_file, "GTIN,Name\n2223334445556,Updated product name\n,Product without an identifier\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $importer = new WC_Product_CSV_Importer(
+ $csv_file,
+ array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'GTIN' => 'global_unique_id',
+ 'Name' => 'name',
+ ),
+ )
+ );
+ $data = $importer->import();
+
+ wp_delete_file( $csv_file );
+
+ $this->assertSame( array( $product->get_id() ), $data['updated'] );
+ $this->assertCount( 1, $data['imported'] );
+ $this->assertEmpty( $data['failed'] );
+ $this->assertEmpty( $data['skipped'] );
+ $this->assertSame( 'Updated product name', wc_get_product( $product->get_id() )->get_name() );
+ $this->assertSame( 'Product without an identifier', wc_get_product( $data['imported'][0] )->get_name() );
+ $this->assertSame( '', wc_get_product( $data['imported'][0] )->get_global_unique_id() );
+
+ WC_Helper_Product::delete_product( $data['imported'][0] );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
+ /**
+ * @testdox A Global Unique ID that strips to nothing creates a product, like a blank cell, when updating existing products.
+ */
+ public function test_import_creates_products_whose_global_unique_id_strips_to_nothing() {
+ // "ABC" is saved as a blank Global Unique ID, so the row supplies no match key at all.
+ $csv_file = trailingslashit( get_temp_dir() ) . 'import-invalid-global-unique-id.csv';
+ file_put_contents( $csv_file, "GTIN,Name\nABC,Product without a usable identifier\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $importer = new WC_Product_CSV_Importer(
+ $csv_file,
+ array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'GTIN' => 'global_unique_id',
+ 'Name' => 'name',
+ ),
+ )
+ );
+ $data = $importer->import();
+
+ wp_delete_file( $csv_file );
+
+ $this->assertEmpty( $data['updated'] );
+ $this->assertCount( 1, $data['imported'] );
+ $this->assertEmpty( $data['failed'] );
+ $this->assertEmpty( $data['skipped'] );
+ $this->assertSame( 'Product without a usable identifier', wc_get_product( $data['imported'][0] )->get_name() );
+ $this->assertSame( '', wc_get_product( $data['imported'][0] )->get_global_unique_id() );
+
+ WC_Helper_Product::delete_product( $data['imported'][0] );
+ }
+
+ /**
+ * @testdox A Global Unique ID that matches a product of a different type fails the row instead of converting the product.
+ */
+ public function test_import_fails_global_unique_id_rows_that_match_a_different_product_type() {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_name( 'Original product name' );
+ $product->set_global_unique_id( '5556667778889' );
+ $product->save();
+ $category_ids = wp_set_object_terms( $product->get_id(), 'GTIN category', 'product_cat' );
+
+ $csv_file = trailingslashit( get_temp_dir() ) . 'import-global-unique-id-type-mismatch.csv';
+ file_put_contents( $csv_file, "Type,GTIN,Name\nvariation,5556667778889,Updated product name\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $importer = new WC_Product_CSV_Importer(
+ $csv_file,
+ array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'Type' => 'type',
+ 'GTIN' => 'global_unique_id',
+ 'Name' => 'name',
+ ),
+ )
+ );
+ $data = $importer->import();
+
+ wp_delete_file( $csv_file );
+
+ $this->assertEmpty( $data['updated'] );
+ $this->assertEmpty( $data['imported'] );
+ $this->assertEmpty( $data['imported_variations'] );
+ $this->assertEmpty( $data['skipped'] );
+ $this->assertCount( 1, $data['failed'] );
+ $this->assertSame( 'The Global Unique ID matches a product of a different type.', $data['failed'][0]->get_error_message() );
+ $this->assertSame( 'product', get_post_type( $product->get_id() ) );
+ $this->assertSame( 'Original product name', wc_get_product( $product->get_id() )->get_name() );
+ $this->assertSame( $category_ids, wp_get_object_terms( $product->get_id(), 'product_cat', array( 'fields' => 'ids' ) ) );
+
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
+ /**
+ * @testdox A Global Unique ID that matches a variable product fails a row whose blank Type defaults to simple.
+ */
+ public function test_import_fails_global_unique_id_rows_that_would_change_the_product_type() {
+ $product = WC_Helper_Product::create_variation_product();
+ $product->set_name( 'Original variable product name' );
+ $product->set_global_unique_id( '6667778889990' );
+ $product->save();
+
+ $csv_file = trailingslashit( get_temp_dir() ) . 'import-global-unique-id-blank-type.csv';
+ file_put_contents( $csv_file, "Type,GTIN,Name\n,6667778889990,Updated product name\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $importer = new WC_Product_CSV_Importer(
+ $csv_file,
+ array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'Type' => 'type',
+ 'GTIN' => 'global_unique_id',
+ 'Name' => 'name',
+ ),
+ )
+ );
+ $data = $importer->import();
+
+ wp_delete_file( $csv_file );
+
+ $this->assertEmpty( $data['updated'] );
+ $this->assertEmpty( $data['imported'] );
+ $this->assertEmpty( $data['skipped'] );
+ $this->assertCount( 1, $data['failed'] );
+ $this->assertSame( 'The Global Unique ID matches a product of a different type.', $data['failed'][0]->get_error_message() );
+ $this->assertSame( ProductType::VARIABLE, WC_Product_Factory::get_product_type( $product->get_id() ) );
+ $this->assertSame( 'Original variable product name', wc_get_product( $product->get_id() )->get_name() );
+
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
+ /**
+ * @testdox An unmatched SKU is not matched by Global Unique ID when updating existing products.
+ */
+ public function test_import_does_not_fall_back_to_global_unique_id_when_the_sku_is_unmatched() {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_name( 'Original product name' );
+ $product->set_global_unique_id( '4443332221110' );
+ $product->save();
+
+ $csv_file = trailingslashit( get_temp_dir() ) . 'import-unmatched-sku-with-global-unique-id.csv';
+ file_put_contents( $csv_file, "SKU,GTIN,Name\nNEW-SKU-4443332221110,4443332221110,Updated product name\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $importer = new WC_Product_CSV_Importer(
+ $csv_file,
+ array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'SKU' => 'sku',
+ 'GTIN' => 'global_unique_id',
+ 'Name' => 'name',
+ ),
+ )
+ );
+ $data = $importer->import();
+
+ wp_delete_file( $csv_file );
+
+ $this->assertEmpty( $data['updated'] );
+ $this->assertEmpty( $data['imported'] );
+ $this->assertEmpty( $data['failed'] );
+ $this->assertCount( 1, $data['skipped'] );
+ $this->assertSame( 'No matching product exists to update.', $data['skipped'][0]->get_error_message() );
+ $this->assertSame( 'Original product name', wc_get_product( $product->get_id() )->get_name() );
+
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
+ /**
+ * @testdox Without update existing, an unmatched Global Unique ID creates a product and a match does not update one.
+ */
+ public function test_import_does_not_update_by_global_unique_id_when_updates_are_disabled() {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_name( 'Original product name' );
+ $product->set_global_unique_id( '7654321098765' );
+ $product->save();
+
+ $csv_file = trailingslashit( get_temp_dir() ) . 'import-existing-global-unique-id-with-updates-disabled.csv';
+ file_put_contents( $csv_file, "GTIN,Name\n7654321098765,Updated product name\n1112223334445,New product name\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $importer = new WC_Product_CSV_Importer(
+ $csv_file,
+ array(
+ 'parse' => true,
+ 'mapping' => array(
+ 'GTIN' => 'global_unique_id',
+ 'Name' => 'name',
+ ),
+ )
+ );
+ $data = $importer->import();
+
+ wp_delete_file( $csv_file );
+
+ $this->assertEmpty( $data['updated'] );
+ $this->assertCount( 1, $data['imported'] );
+ $this->assertEmpty( $data['imported_variations'] );
+ $this->assertCount( 1, $data['failed'] );
+ $this->assertEmpty( $data['skipped'] );
+ $this->assertSame( 'Original product name', wc_get_product( $product->get_id() )->get_name() );
+ $this->assertSame( 'New product name', wc_get_product( $data['imported'][0] )->get_name() );
+ $this->assertSame( '1112223334445', wc_get_product( $data['imported'][0] )->get_global_unique_id() );
+
+ WC_Helper_Product::delete_product( $data['imported'][0] );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
/**
* @testdox Test that new variations of an existing variable product are created when updating existing products.
*/
@@ -464,6 +775,126 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
WC_Helper_Product::delete_product( $product->get_id() );
}
+ /**
+ * @testdox Test that a variation row with a Global Unique ID but no SKU is created and then matched again on re-import.
+ */
+ public function test_import_creates_new_variations_identified_only_by_a_global_unique_id() {
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_name( 'Size' );
+ $attribute->set_options( array( 'S', 'M' ) );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import GTIN Tee' );
+ $product->set_sku( 'IMPORT-GTIN-PARENT' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $csv_file = trailingslashit( get_temp_dir() ) . 'import-variation-by-global-unique-id.csv';
+ file_put_contents( $csv_file, "Type,GTIN,Name,Parent,Attribute 1 name,Attribute 1 value(s),Regular price\nvariation,4001234567890,Import GTIN Tee - S,IMPORT-GTIN-PARENT,Size,S,10\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $args = array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'Type' => 'type',
+ 'GTIN' => 'global_unique_id',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ 'Attribute 1 name' => 'attributes:name1',
+ 'Attribute 1 value(s)' => 'attributes:value1',
+ 'Regular price' => 'regular_price',
+ ),
+ );
+
+ $importer = new WC_Product_CSV_Importer( $csv_file, $args );
+ $data = $importer->import();
+
+ $this->assertCount( 1, $data['imported_variations'], 'Expected 1 imported variation, got ' . count( $data['imported_variations'] ) );
+ $this->assertEmpty( $data['skipped'], 'Expected 0 skipped rows, got ' . count( $data['skipped'] ) );
+ $this->assertEmpty( $data['failed'], 'Expected 0 failed rows, got ' . count( $data['failed'] ) );
+
+ $variation_id = $data['imported_variations'][0];
+ $variation = wc_get_product( $variation_id );
+ $this->assertSame( '4001234567890', $variation->get_global_unique_id(), 'Expected the new variation to keep the Global Unique ID it was created with' );
+ $this->assertSame( '', $variation->get_sku( 'edit' ), 'Expected the new variation to have no SKU of its own' );
+
+ // The same file again: the row must now match the variation it created rather than duplicate it.
+ file_put_contents( $csv_file, "Type,GTIN,Name,Parent,Attribute 1 name,Attribute 1 value(s),Regular price\nvariation,4001234567890,Import GTIN Tee - S,IMPORT-GTIN-PARENT,Size,S,25\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $reimporter = new WC_Product_CSV_Importer( $csv_file, $args );
+ $reimported = $reimporter->import();
+ wp_delete_file( $csv_file );
+
+ $this->assertSame( array( $variation_id ), $reimported['updated'], 'Expected the re-imported row to update the variation it created' );
+ $this->assertEmpty( $reimported['imported_variations'], 'Expected the re-import to create no further variations' );
+ $this->assertSame( '25', wc_get_product( $variation_id )->get_regular_price(), 'Expected the re-import to update the variation price' );
+
+ $variations = wc_get_products(
+ array(
+ 'type' => ProductType::VARIATION,
+ 'parent' => $product->get_id(),
+ )
+ );
+ $this->assertCount( 1, $variations, 'Expected the two imports to leave a single variation' );
+
+ WC_Helper_Product::delete_product( $variation_id );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
+ /**
+ * @testdox Test that a variation row with a blank SKU is skipped when its Global Unique ID strips to nothing.
+ */
+ public function test_import_skips_new_variations_whose_global_unique_id_strips_to_nothing() {
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_name( 'Size' );
+ $attribute->set_options( array( 'S', 'M' ) );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import GTIN Tee' );
+ $product->set_sku( 'IMPORT-GTIN-PARENT' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ // "n/a" is saved as a blank Global Unique ID, so it is no more of a re-import key than the blank SKU is.
+ $csv_file = trailingslashit( get_temp_dir() ) . 'import-variation-unusable-global-unique-id.csv';
+ file_put_contents( $csv_file, "Type,SKU,GTIN,Name,Parent,Attribute 1 name,Attribute 1 value(s)\nvariation,,n/a,Import GTIN Tee - S,IMPORT-GTIN-PARENT,Size,S\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $importer = new WC_Product_CSV_Importer(
+ $csv_file,
+ array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'GTIN' => 'global_unique_id',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ 'Attribute 1 name' => 'attributes:name1',
+ 'Attribute 1 value(s)' => 'attributes:value1',
+ ),
+ )
+ );
+ $data = $importer->import();
+ wp_delete_file( $csv_file );
+
+ $this->assertEmpty( $data['imported_variations'], 'Expected 0 imported variations, got ' . count( $data['imported_variations'] ) );
+ $this->assertCount( 1, $data['skipped'], 'Expected 1 skipped row, got ' . count( $data['skipped'] ) );
+ $this->assertSame( 'A new variation cannot be created without a SKU or a GTIN, UPC, EAN, or ISBN.', $data['skipped'][0]->get_error_message() );
+
+ $variations = wc_get_products(
+ array(
+ 'type' => ProductType::VARIATION,
+ 'parent' => $product->get_id(),
+ )
+ );
+ $this->assertCount( 0, $variations, 'Expected no variation to be created for a row with no usable key' );
+
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
/**
* @testdox Test that a variation row whose ID belongs to an existing post of another type is skipped when updating existing products, even if the filter tries to force it.
*/