diff --git a/includes/admin/class-wc-admin-duplicate-product.php b/includes/admin/class-wc-admin-duplicate-product.php index c59ded77e73..e6886f3ba3e 100644 --- a/includes/admin/class-wc-admin-duplicate-product.php +++ b/includes/admin/class-wc-admin-duplicate-product.php @@ -1,17 +1,15 @@ -
- ID ) ), 'woocommerce-duplicate-product_' . $post->ID ); + ?> +
+ product_duplicate( $product ); @@ -120,7 +116,7 @@ class WC_Admin_Duplicate_Product { do_action( 'woocommerce_product_duplicate', $duplicate, $product ); wc_do_deprecated_action( 'woocommerce_duplicate_product', array( $duplicate->get_id(), $this->get_product_to_duplicate( $product_id ) ), '3.0', 'Use woocommerce_product_duplicate action instead.' ); - // Redirect to the edit screen for the new draft page + // Redirect to the edit screen for the new draft page. wp_redirect( admin_url( 'post.php?action=edit&post=' . $duplicate->get_id() ) ); exit; } @@ -128,15 +124,20 @@ class WC_Admin_Duplicate_Product { /** * Function to create the duplicate of the product. * - * @param WC_Product $product - * @return WC_Product + * @param WC_Product $product The product to duplicate. + * @return WC_Product The duplicate. */ public function product_duplicate( $product ) { - // Filter to allow us to unset/remove data we don't want to copy to the duplicate. @since 2.6 + /** + * Filter to allow us to unset/remove data we don't want to copy to the duplicate. + * + * @since 2.6 + */ $meta_to_exclude = array_filter( apply_filters( 'woocommerce_duplicate_product_exclude_meta', array() ) ); $duplicate = clone $product; $duplicate->set_id( 0 ); + /* translators: %s contains the name of the original product. */ $duplicate->set_name( sprintf( esc_html__( '%s (Copy)', 'woocommerce' ), $duplicate->get_name() ) ); $duplicate->set_total_sales( 0 ); if ( '' !== $product->get_sku( 'edit' ) ) { @@ -153,7 +154,11 @@ class WC_Admin_Duplicate_Product { $duplicate->delete_meta_data( $meta_key ); } - // This action can be used to modify the object further before it is created - it will be passed by reference. @since 3.0 + /** + * This action can be used to modify the object further before it is created - it will be passed by reference. + * + * @since 3.0 + */ do_action( 'woocommerce_product_duplicate_before_save', $duplicate, $product ); // Save parent product. @@ -168,6 +173,12 @@ class WC_Admin_Duplicate_Product { $child_duplicate->set_id( 0 ); $child_duplicate->set_date_created( null ); + // If we wait and let the insertion generate the slug, we will see extreme performance degradation + // in the case where a product is used as a template. Every time the template is duplicated, each + // variation will query every consecutive slug until it finds an empty one. To avoid this, we can + // optimize the generation ourselves, avoiding the issue altogether. + $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' ) ) ); } @@ -176,7 +187,11 @@ class WC_Admin_Duplicate_Product { $child_duplicate->delete_meta_data( $meta_key ); } - // This action can be used to modify the object further before it is created - it will be passed by reference. @since 3.0 + /** + * This action can be used to modify the object further before it is created - it will be passed by reference. + * + * @since 3.0 + */ do_action( 'woocommerce_product_duplicate_before_save', $child_duplicate, $child ); $child_duplicate->save(); @@ -193,7 +208,7 @@ class WC_Admin_Duplicate_Product { * Get a product from the database to duplicate. * * @deprecated 3.0.0 - * @param mixed $id + * @param mixed $id The ID of the product to duplicate. * @return object|bool * @see duplicate_product */ @@ -215,6 +230,44 @@ class WC_Admin_Duplicate_Product { return $post; } + + /** + * Generates a unique slug for a given product. We do this so that we can override the + * behavior of wp_unique_post_slug(). The normal slug generation will run single + * select queries on every non-unique slug, resulting in very bad performance. + * + * @param WC_Product $product The product to generate a slug for. + * @since 3.9.0 + */ + private function generate_unique_slug( $product ) { + global $wpdb; + + // We want to remove the suffix from the slug so that we can find the maximum suffix using this root slug. + // 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_slug = preg_replace( '/-[0-9]+$/', '', $product->get_slug() ); + + $results = $wpdb->get_results( + $wpdb->prepare( "SELECT post_name FROM $wpdb->posts WHERE post_name LIKE %s AND post_type IN ( 'product', 'product_variation' )", $root_slug . '%' ) + ); + + // The slug is already unique! + if ( empty( $results ) ) { + return; + } + + // Find the maximum suffix so we can ensure uniqueness. + $max_suffix = 1; + foreach ( $results as $result ) { + // Pull a numerical suffix off the slug after the last hyphen. + $suffix = intval( substr( $result->post_name, strrpos( $result->post_name, '-' ) + 1 ) ); + if ( $suffix > $max_suffix ) { + $max_suffix = $suffix; + } + } + + $product->set_slug( $root_slug . '-' . ( $max_suffix + 1 ) ); + } } return new WC_Admin_Duplicate_Product(); diff --git a/tests/unit-tests/admin/class-wc-tests-admin-duplicate-product.php b/tests/unit-tests/admin/class-wc-tests-admin-duplicate-product.php new file mode 100644 index 00000000000..aa1671d47a9 --- /dev/null +++ b/tests/unit-tests/admin/class-wc-tests-admin-duplicate-product.php @@ -0,0 +1,110 @@ +product_duplicate( $product ); + + $this->assertNotEquals( $product->get_id(), $duplicate->get_id() ); + $this->assertEquals( $product->get_name() . ' (Copy)', $duplicate->get_name() ); + $this->assertEquals( 'draft', $duplicate->get_status() ); + $this->assertDuplicateWasReset( $duplicate ); + } + + /** + * Test duplication of a variable product. + */ + public function test_variable_product_duplication() { + $product = WC_Helper_Product::create_variation_product(); + + $duplicate = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product ); + + $this->assertNotEquals( $product->get_id(), $duplicate->get_id() ); + $this->assertEquals( $product->get_name() . ' (Copy)', $duplicate->get_name() ); + $this->assertEquals( 'draft', $duplicate->get_status() ); + $this->assertDuplicateWasReset( $duplicate ); + + $product_children = $product->get_children(); + $duplicate_children = $duplicate->get_children(); + $child_count = count( $product_children ); + $this->assertEquals( $child_count, count( $duplicate_children ) ); + + for ( $i = 0; $i < $child_count; $i++ ) { + $product_child = wc_get_product( $product_children[ $i ] ); + $duplicate_child = wc_get_product( $duplicate_children[ $i ] ); + + $this->assertNotEquals( $product_child->get_id(), $duplicate_child->get_id() ); + $this->assertEquals( $product_child->get_name() . ' (Copy)', $duplicate_child->get_name() ); + $this->assertEquals( 'publish', $duplicate_child->get_status() ); + $this->assertDuplicateWasReset( $duplicate_child ); + } + } + + /** + * Tests that the unique slugs generated by variation duplication are correct. + */ + public function test_variable_product_duplication_unique_slug_generation() { + $product = WC_Helper_Product::create_variation_product(); + + // We just want to make sure each successive duplication has the correct slugs. + $slug_matches = array( + array( + 'dummy-variable-product-small-2', + 'dummy-variable-product-large-2', + 'dummy-variable-product-3', + 'dummy-variable-product-4', + ), + array( + 'dummy-variable-product-small-3', + 'dummy-variable-product-large-3', + 'dummy-variable-product-5', + 'dummy-variable-product-6', + ), + array( + 'dummy-variable-product-small-4', + 'dummy-variable-product-large-4', + 'dummy-variable-product-7', + 'dummy-variable-product-8', + ), + ); + + foreach ( $slug_matches as $slug_match ) { + $duplicate = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product ); + + $duplicate_children = $duplicate->get_children(); + + $this->assertEquals( 4, count( $duplicate_children ) ); + foreach ( $slug_match as $key => $slug ) { + $child = wc_get_product( $duplicate_children[ $key ] ); + $this->assertEquals( $slug, $child->get_slug() ); + } + } + } + + /** + * Asserts that the product was correctly reset after duplication. + * + * @param WC_Product $duplicate The duplicate product to evaluate. + */ + private function assertDuplicateWasReset( $duplicate ) { + $this->assertEquals( 0, $duplicate->get_total_sales() ); + $this->assertEquals( array(), $duplicate->get_rating_counts() ); + $this->assertEquals( 0, $duplicate->get_average_rating() ); + $this->assertEquals( 0, $duplicate->get_rating_count() ); + } +}