Commit 5c42d25e0f for woocommerce
commit 5c42d25e0fa9227c70c12b9a11c709e4137275a0
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date: Thu Jan 8 10:00:23 2026 +0100
Fix error when duplicating a product using an SKU of a trashed product (#62628)
* Fix error when duplicating a product using an SKU of a trashed product
* Add changelog file
* Change with param
* Use sanitize_key instead of esc_sql
* Fixes
* Improve comments
* Fix wrong assertion message
* Create is_existing_sku_with_excluded_statuses() method
* Create a generate_unique_sku() directly in WC_Admin_Duplicate_Product
* Update PHPStan
* Fix tests
* Use esc_like instead of wp_slash
* Add support for existing PHP filters
* Fix variable name in tests
* Strengthen LIKE wildcard
* Add wc_doing_it_wrong() functions
* Revert "Strengthen LIKE wildcard"
This reverts commit 8648f04c8e0f469e1e8989389765bca74a203208.
* Strengthen LIKE wildcard (II)
diff --git a/plugins/woocommerce/changelog/fix-62616-duplicate-trashed-sku b/plugins/woocommerce/changelog/fix-62616-duplicate-trashed-sku
new file mode 100644
index 0000000000..38b12148d0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-62616-duplicate-trashed-sku
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent an error that occurred when duplicating a product if the duplicate's SKU matched the SKU of a trashed product
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 11c6951ff7..6e63a047e2 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php
@@ -131,6 +131,11 @@ class WC_Admin_Duplicate_Product {
* @return WC_Product The duplicate.
*/
public function product_duplicate( $product ) {
+ if ( ! $product instanceof WC_Product ) {
+ wc_doing_it_wrong( __METHOD__, 'product_duplicate() expects a WC_Product instance', '10.5.0' );
+ return new WC_Product();
+ }
+
/**
* Filter to allow us to exclude meta keys from product duplication..
*
@@ -157,7 +162,7 @@ class WC_Admin_Duplicate_Product {
$duplicate->set_name( sprintf( esc_html__( '%s (Copy)', 'woocommerce' ), $duplicate->get_name() ) );
$duplicate->set_total_sales( 0 );
if ( '' !== $product->get_sku( 'edit' ) ) {
- $duplicate->set_sku( wc_product_generate_unique_sku( 0, $product->get_sku( 'edit' ) ) );
+ $this->generate_unique_sku( $duplicate );
}
if ( '' !== $product->get_global_unique_id( 'edit' ) ) {
$duplicate->set_global_unique_id( '' );
@@ -191,6 +196,12 @@ class WC_Admin_Duplicate_Product {
if ( ! apply_filters( 'woocommerce_duplicate_product_exclude_children', false, $product ) && $product->is_type( ProductType::VARIABLE ) ) {
foreach ( $product->get_children() as $child_id ) {
$child = wc_get_product( $child_id );
+
+ if ( ! $child instanceof WC_Product ) {
+ wc_doing_it_wrong( __METHOD__, 'product_duplicate() expects product children to be WC_Product instances', '10.5.0' );
+ continue;
+ }
+
$child->read_meta_data();
$child_duplicate = clone $child;
$child_duplicate->set_parent_id( $duplicate->get_id() );
@@ -204,7 +215,7 @@ class WC_Admin_Duplicate_Product {
$this->generate_unique_slug( $child_duplicate );
if ( '' !== $child->get_sku( 'edit' ) ) {
- $child_duplicate->set_sku( wc_product_generate_unique_sku( 0, $child->get_sku( 'edit' ) ) );
+ $this->generate_unique_sku( $child_duplicate );
}
if ( '' !== $child->get_global_unique_id( 'edit' ) ) {
$child_duplicate->set_global_unique_id( '' );
@@ -295,6 +306,98 @@ class WC_Admin_Duplicate_Product {
$product->set_slug( $root_slug . '-' . ( $max_suffix + 1 ) );
}
+
+ /**
+ * Generates a unique sku for a given product.
+ *
+ * @param WC_Product $product The product to generate a sku for.
+ * @return void
+ * @since 10.5.0
+ *
+ * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
+ */
+ 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() );
+
+ // If the parent product has no SKU, don't do anything.
+ if ( ! $root_sku ) {
+ return;
+ }
+
+ $existing_skus = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT lookup.sku
+ FROM {$wpdb->posts} as posts
+ 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 ) . '%'
+ )
+ );
+
+ // The sku is already unique!
+ if ( empty( $existing_skus ) ) {
+ $product->set_sku( $root_sku );
+ return;
+ }
+
+ // Find the maximum suffix so we can ensure uniqueness.
+ $max_suffix = 0;
+ 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;
+ }
+ }
+
+ // We set a limit of SKUs to try in order to avoid infinite loops.
+ $limit = $max_suffix + 100;
+ $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()`.
+ * 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;
+ }
+ }
+ ++$max_suffix;
+ }
+ }
}
return new WC_Admin_Duplicate_Product();
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 5bd8a2c10a..061394480e 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -2508,30 +2508,6 @@ parameters:
count: 1
path: includes/admin/class-wc-admin-duplicate-product.php
- -
- message: '#^Cannot call method get_global_unique_id\(\) on WC_Product\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/admin/class-wc-admin-duplicate-product.php
-
- -
- message: '#^Cannot call method get_sku\(\) on WC_Product\|false\|null\.$#'
- identifier: method.nonObject
- count: 2
- path: includes/admin/class-wc-admin-duplicate-product.php
-
- -
- message: '#^Cannot call method read_meta_data\(\) on WC_Product\|false\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: includes/admin/class-wc-admin-duplicate-product.php
-
- -
- message: '#^Cannot clone non\-object variable \$child of type WC_Product\|false\|null\.$#'
- identifier: clone.nonObject
- count: 1
- path: includes/admin/class-wc-admin-duplicate-product.php
-
-
message: '#^Method WC_Admin_Duplicate_Product\:\:dupe_button\(\) has no return type specified\.$#'
identifier: missingType.return
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 a9e5e7934c..5b39e2e3f3 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
@@ -1,4 +1,6 @@
<?php
+declare( strict_types = 1 );
+
/**
* Unit tests for the WC_Admin_Duplicate_Product class.
*
@@ -9,6 +11,38 @@
* Class WC_Admin_Duplicate_Product_Test
*/
class WC_Admin_Duplicate_Product_Test extends WC_Unit_Test_Case {
+ /**
+ * Don't allow SKUs with numbers lower than 6. Used to hook into `wc_product_pre_has_unique_sku`.
+ *
+ * @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.
+ * @return bool
+ */
+ public function dont_allow_skus_with_numbers_lower_than_6( $has_unique_sku, $product_id, $sku ) {
+ if ( preg_match( '/[0-5]/', $sku ) ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Don't allow SKUs with numbers lower than 8. Used to hook into `wc_product_has_unique_sku`.
+ *
+ * @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.
+ * @return bool
+ */
+ public function dont_allow_skus_with_numbers_lower_than_8( $sku_found, $product_id, $sku ) {
+ if ( preg_match( '/[0-7]/', $sku ) ) {
+ return true;
+ }
+
+ return false;
+ }
+
/**
* Tests that the filter will exclude metadata from the duplicate as-expected.
*/
@@ -29,4 +63,51 @@ class WC_Admin_Duplicate_Product_Test extends WC_Unit_Test_Case {
$this->assertNotEquals( $product->get_id(), $duplicate->get_id() );
$this->assertEmpty( $duplicate->get_meta_data() );
}
+
+ /**
+ * Tests that duplicating a product correctly handles trashed products with indexed SKUs.
+ *
+ * When duplicating a product with SKU 'woo-cap', if there's a trashed product
+ * with SKU 'woo-cap-1', the duplicate should get SKU 'woo-cap-2' instead of throwing an error.
+ */
+ public function test_duplicate_product_skips_trashed_products_with_indexed_sku() {
+ // Create a product with SKU 'woo-cap'.
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_sku( 'woo-cap' );
+ $product->save();
+
+ // Create a duplicate and trash it.
+ $first_duplicate = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product );
+ $first_duplicate->save();
+ $first_duplicate->delete();
+
+ // Verify the trashed product is in trash.
+ $this->assertEquals( 'trash', $first_duplicate->get_status() );
+
+ // Duplicate the original product.
+ $second_duplicate = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product );
+
+ // Verify the duplicate was created successfully and has SKU 'woo-cap-2'
+ // (skipping 'woo-cap-1' which is trashed).
+ $this->assertEquals( 'woo-cap-2', $second_duplicate->get_sku() );
+ $this->assertNotEquals( $product->get_id(), $second_duplicate->get_id() );
+
+ // Verify the filter 'wc_product_pre_has_unique_sku' is honored.
+ add_filter( 'wc_product_pre_has_unique_sku', array( $this, 'dont_allow_skus_with_numbers_lower_than_6' ), 10, 3 );
+
+ $third_duplicate = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product );
+ $this->assertEquals( 'woo-cap-6', $third_duplicate->get_sku() );
+ $this->assertNotEquals( $product->get_id(), $third_duplicate->get_id() );
+
+ remove_filter( 'wc_product_pre_has_unique_sku', array( $this, 'dont_allow_skus_with_numbers_lower_than_6' ) );
+
+ // Verify the filter 'wc_product_has_unique_sku' is honored.
+ add_filter( 'wc_product_has_unique_sku', array( $this, 'dont_allow_skus_with_numbers_lower_than_8' ), 10, 3 );
+
+ $fourth_duplicate = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product );
+ $this->assertEquals( 'woo-cap-8', $fourth_duplicate->get_sku() );
+ $this->assertNotEquals( $product->get_id(), $fourth_duplicate->get_id() );
+
+ remove_filter( 'wc_product_has_unique_sku', array( $this, 'dont_allow_skus_with_numbers_lower_than_8' ) );
+ }
}