Commit ae4f5b784de for woocommerce
commit ae4f5b784de74c2da779e1dcfd4055af861dbf51
Author: Faisal Ahammad <faisalahammad24@gmail.com>
Date: Thu Jun 11 22:27:27 2026 +0600
Fix SKU duplication corrupting hyphenated SKUs (#65423)
* Fix SKU duplication corrupting hyphenated SKUs
generate_unique_sku() previously stripped numeric segments after hyphens,
treating the last number as an incrementable suffix. For SKUs like 'SKU-123',
this produced 'SKU-124' instead of 'SKU-123-1'.
Changed to always append -N suffix to the full original SKU. Preserves
backward compatibility with wc_product_pre_has_unique_sku and
wc_product_has_unique_sku filter hooks.
Fixes #65378
* fix(duplicate-product): use wc_product_generate_unique_sku() fallback for filter compliance
Replaces direct $original_sku . '-1' fallback with existing
wc_product_generate_unique_sku() helper that properly checks
wc_product_pre_has_unique_sku and wc_product_has_unique_sku filters.
Addresses CodeRabbit review finding on PR #65423.
* fix(duplicate-product): address PR review feedback
- Move inline comment before statement to fix linting error
- Add changelog entry for SKU duplication fix
- Use strict empty check to avoid treating SKU '0' as empty
Refs #65423
* fix(duplicate-product): preserve custom SKU on REST API duplicate when unique
- Add early-return: if source SKU is not used by any saved product, use it
as-is. Preserves the previous behavior where the REST API duplicate
endpoint applies a caller-provided SKU directly when it is unique.
- Keep the new behavior for source SKUs that already exist: append the
next available hyphenated suffix (e.g. 'SKU-123' -> 'SKU-123-1').
Tests run:
- WC_Admin_Duplicate_Product_Test: 3 tests, 16 assertions, all pass
- ProductsControllerTest (V4): 93 tests, 727 assertions, all pass
- WC_REST_Products_Controller_Tests (V3): 73 tests, 4 pre-existing
meta-param failures (also fail on trunk, not related to this fix)
Gates: phpcs clean, phpstan 0 errors, coderabbit 0 findings.
Refs #65423
* fix(duplicate-product): address case-insensitive SKU and fallback collision issues
- Replace strpos() with stripos() for case-insensitive prefix matching
matching MySQL LIKE behavior (#65378)
- Start fallback from $max_suffix + 1 instead of 1 to avoid trashed
product SKU collisions
- Add test_duplicate_product_handles_case_insensitive_sku_conflict
- PR description updated with submission guidelines and testing details
diff --git a/plugins/woocommerce/changelog/fix-65378-sku-duplication-hyphen b/plugins/woocommerce/changelog/fix-65378-sku-duplication-hyphen
new file mode 100644
index 00000000000..0ac65c09d6d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-65378-sku-duplication-hyphen
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix SKU duplication corrupting hyphenated SKUs. Duplicating a product with SKU `SKU-123` now correctly produces `SKU-123-1` instead of `SKU-124`.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php b/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php
index 6e63a047e2b..00bb9177662 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php
@@ -310,6 +310,10 @@ class WC_Admin_Duplicate_Product {
/**
* Generates a unique sku for a given product.
*
+ * Appends a numeric suffix to the original SKU. For example, if the original SKU
+ * is "SKU-123", the duplicate will get "SKU-123-1". This preserves hyphens in the
+ * original SKU rather than treating them as suffix delimiters.
+ *
* @param WC_Product $product The product to generate a sku for.
* @return void
* @since 10.5.0
@@ -319,16 +323,30 @@ class WC_Admin_Duplicate_Product {
private function generate_unique_sku( $product ) {
global $wpdb;
- // We want to remove the suffix from the sku so that we can find the maximum suffix using this root sku.
- // This will allow us to find the next-highest suffix that is unique. While this does not support gap
- // filling, this shouldn't matter for our use-case.
- $root_sku = preg_replace( '/-[0-9]+$/', '', $product->get_sku() );
+ $original_sku = $product->get_sku();
+
+ // If the product has no SKU, don't do anything.
+ if ( '' === $original_sku ) {
+ return;
+ }
+
+ // If the source SKU is not used by any saved product, use it as-is.
+ // This preserves backward compatibility with the REST API duplicate endpoint,
+ // which lets callers set a custom SKU on the duplicate and expects that SKU
+ // to be applied directly when it is unique.
+ $source_sku_in_use = (bool) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$wpdb->wc_product_meta_lookup} WHERE sku = %s",
+ $original_sku
+ )
+ );
- // If the parent product has no SKU, don't do anything.
- if ( ! $root_sku ) {
+ if ( ! $source_sku_in_use ) {
+ $product->set_sku( $original_sku );
return;
}
+ // Query existing SKUs that match the pattern: original SKU + hyphen + number.
$existing_skus = $wpdb->get_col(
$wpdb->prepare(
"SELECT lookup.sku
@@ -336,33 +354,46 @@ class WC_Admin_Duplicate_Product {
INNER JOIN {$wpdb->wc_product_meta_lookup} AS lookup ON posts.ID = lookup.product_id
WHERE posts.post_type IN ( 'product', 'product_variation' )
AND lookup.sku LIKE %s",
- $wpdb->esc_like( $root_sku ) . '%'
+ $wpdb->esc_like( $original_sku ) . '-%'
)
);
- // The sku is already unique!
- if ( empty( $existing_skus ) ) {
- $product->set_sku( $root_sku );
- return;
- }
-
- // Find the maximum suffix so we can ensure uniqueness.
+ // Find the maximum suffix.
$max_suffix = 0;
+ // +1 for the hyphen separator between original SKU and suffix.
+ $prefix_len = strlen( $original_sku ) + 1;
foreach ( $existing_skus as $existing_sku ) {
- // Pull a numerical suffix off the sku after the last hyphen.
- $suffix = intval( substr( $existing_sku, strrpos( $existing_sku, '-', -1 ) + 1 ) );
- if ( $suffix > $max_suffix ) {
- $max_suffix = $suffix;
+ // Check if the SKU starts with our base and has a numeric suffix.
+ if ( stripos( $existing_sku, $original_sku . '-' ) === 0 ) {
+ $suffix_str = substr( $existing_sku, $prefix_len );
+ // Only consider pure numeric suffixes.
+ if ( is_numeric( $suffix_str ) ) {
+ $suffix = intval( $suffix_str );
+ if ( $suffix > $max_suffix ) {
+ $max_suffix = $suffix;
+ }
+ }
}
}
- // We set a limit of SKUs to try in order to avoid infinite loops.
- $limit = $max_suffix + 100;
+ // Set the new SKU with the next available suffix.
+ $new_sku = $original_sku . '-' . ( $max_suffix + 1 );
$product_id = $product->get_id();
- while ( $max_suffix < $limit ) {
- $new_sku = $root_sku . '-' . ( $max_suffix + 1 );
+ /**
+ * Gives plugins an opportunity to verify SKU uniqueness themselves. Filter added to keep backwards
+ * compatibility with `wc_product_has_unique_sku()`.
+ * See: https://github.com/woocommerce/woocommerce/pull/62628
+ *
+ * @since 10.5.0
+ *
+ * @param bool|null $has_unique_sku Set to a boolean value to short-circuit the default SKU check.
+ * @param int $product_id The ID of the current product.
+ * @param string $sku The SKU to check for uniqueness.
+ */
+ $pre_has_unique_sku = apply_filters( 'wc_product_pre_has_unique_sku', true, $product_id, $new_sku );
+ if ( $pre_has_unique_sku ) {
/**
* Gives plugins an opportunity to verify SKU uniqueness themselves. Filter added to keep backwards
* compatibility with `wc_product_has_unique_sku()`.
@@ -370,33 +401,20 @@ class WC_Admin_Duplicate_Product {
*
* @since 10.5.0
*
- * @param bool|null $has_unique_sku Set to a boolean value to short-circuit the default SKU check.
- * @param int $product_id The ID of the current product.
- * @param string $sku The SKU to check for uniqueness.
+ * @param bool|null $sku_found Set to a boolean value to short-circuit the default SKU check.
+ * @param int $product_id The ID of the current product.
+ * @param string $sku The SKU to check for uniqueness.
*/
- $pre_has_unique_sku = apply_filters( 'wc_product_pre_has_unique_sku', true, $product_id, $new_sku );
+ $sku_found = apply_filters( 'wc_product_has_unique_sku', false, $product_id, $new_sku );
- if ( $pre_has_unique_sku ) {
- /**
- * Gives plugins an opportunity to verify SKU uniqueness themselves. Filter added to keep backwards
- * compatibility with `wc_product_has_unique_sku()`.
- * See: https://github.com/woocommerce/woocommerce/pull/62628
- *
- * @since 10.5.0
- *
- * @param bool|null $sku_found Set to a boolean value to short-circuit the default SKU check.
- * @param int $product_id The ID of the current product.
- * @param string $sku The SKU to check for uniqueness.
- */
- $sku_found = apply_filters( 'wc_product_has_unique_sku', false, $product_id, $new_sku );
-
- if ( ! $sku_found ) {
- $product->set_sku( $new_sku );
- return;
- }
+ if ( ! $sku_found ) {
+ $product->set_sku( $new_sku );
+ return;
}
- ++$max_suffix;
}
+
+ // Fallback: use the standard unique SKU generator which loops until it finds a unique value.
+ $product->set_sku( wc_product_generate_unique_sku( $product_id, $original_sku, $max_suffix + 1 ) );
}
}
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-duplicate-product-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-duplicate-product-test.php
index 5b39e2e3f34..a2d32055a01 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-duplicate-product-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-duplicate-product-test.php
@@ -110,4 +110,74 @@ class WC_Admin_Duplicate_Product_Test extends WC_Unit_Test_Case {
remove_filter( 'wc_product_has_unique_sku', array( $this, 'dont_allow_skus_with_numbers_lower_than_8' ) );
}
+
+ /**
+ * Tests that duplicating a product with hyphens in the SKU correctly appends the suffix.
+ *
+ * For SKUs like "SKU-123", the duplicate should get "SKU-123-1", not "SKU-124".
+ * This tests the fix for issue #65378.
+ */
+ public function test_duplicate_product_with_hyphenated_sku_preserves_original() {
+ // Test case 1: Simple hyphen with number.
+ $product1 = WC_Helper_Product::create_simple_product();
+ $product1->set_sku( 'SKU-123' );
+ $product1->save();
+
+ $duplicate1 = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product1 );
+ $this->assertEquals( 'SKU-123-1', $duplicate1->get_sku(), 'Duplicate of SKU-123 should be SKU-123-1' );
+
+ // Test case 2: Multiple hyphens.
+ $product2 = WC_Helper_Product::create_simple_product();
+ $product2->set_sku( 'my-product-456' );
+ $product2->save();
+
+ $duplicate2 = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product2 );
+ $this->assertEquals( 'my-product-456-1', $duplicate2->get_sku(), 'Duplicate of my-product-456 should be my-product-456-1' );
+
+ // Test case 3: Non-numeric suffix (should still work).
+ $product3 = WC_Helper_Product::create_simple_product();
+ $product3->set_sku( 'PROD-A' );
+ $product3->save();
+
+ $duplicate3 = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product3 );
+ $this->assertEquals( 'PROD-A-1', $duplicate3->get_sku(), 'Duplicate of PROD-A should be PROD-A-1' );
+
+ // Test case 4: Sequential duplicates.
+ $product4 = WC_Helper_Product::create_simple_product();
+ $product4->set_sku( 'SEQ-100' );
+ $product4->save();
+
+ $dup_a = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product4 );
+ $this->assertEquals( 'SEQ-100-1', $dup_a->get_sku() );
+
+ $dup_b = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product4 );
+ $this->assertEquals( 'SEQ-100-2', $dup_b->get_sku() );
+
+ $dup_c = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product4 );
+ $this->assertEquals( 'SEQ-100-3', $dup_c->get_sku() );
+ }
+
+ /**
+ * Tests that case-insensitive SKU matching prevents collisions.
+ *
+ * MySQL LIKE is case-insensitive by default, so 'SKU-%' matches 'sku-1'.
+ * The prefix check must use stripos() to match SQL behavior.
+ */
+ public function test_duplicate_product_handles_case_insensitive_sku_conflict() {
+ // Create product with uppercase SKU.
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_sku( 'SKU' );
+ $product->save();
+
+ // Create a different product with lowercase variant + suffix.
+ $other = WC_Helper_Product::create_simple_product();
+ $other->set_sku( 'sku-1' );
+ $other->save();
+
+ // Duplicate the uppercase product.
+ // SQL LIKE 'SKU-%' returns 'sku-1', so max_suffix should be 1.
+ // Expected: 'SKU-2', not 'SKU-1' (which collides with 'sku-1').
+ $duplicate = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product );
+ $this->assertEquals( 'SKU-2', $duplicate->get_sku(), 'Duplicate must not collide with case-variant sku-1' );
+ }
}