Merge pull request #25064 from woocommerce/fix/24315
Optimize variable product duplication slug generation
This commit is contained in:
commit
6a395e2485
|
@ -1,17 +1,15 @@
|
||||||
<?php
|
<?php
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duplicate product functionality
|
* Duplicate product functionality
|
||||||
*
|
*
|
||||||
* @author WooCommerce
|
|
||||||
* @category Admin
|
|
||||||
* @package WooCommerce/Admin
|
* @package WooCommerce/Admin
|
||||||
* @version 3.0.0
|
* @version 3.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
if ( class_exists( 'WC_Admin_Duplicate_Product', false ) ) {
|
if ( class_exists( 'WC_Admin_Duplicate_Product', false ) ) {
|
||||||
return new WC_Admin_Duplicate_Product();
|
return new WC_Admin_Duplicate_Product();
|
||||||
}
|
}
|
||||||
|
@ -87,13 +85,11 @@ class WC_Admin_Duplicate_Product {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( isset( $_GET['post'] ) ) {
|
$notify_url = wp_nonce_url( admin_url( 'edit.php?post_type=product&action=duplicate_product&post=' . absint( $post->ID ) ), 'woocommerce-duplicate-product_' . $post->ID );
|
||||||
$notify_url = wp_nonce_url( admin_url( 'edit.php?post_type=product&action=duplicate_product&post=' . absint( $_GET['post'] ) ), 'woocommerce-duplicate-product_' . $_GET['post'] );
|
|
||||||
?>
|
?>
|
||||||
<div id="duplicate-action"><a class="submitduplicate duplication" href="<?php echo esc_url( $notify_url ); ?>"><?php esc_html_e( 'Copy to a new draft', 'woocommerce' ); ?></a></div>
|
<div id="duplicate-action"><a class="submitduplicate duplication" href="<?php echo esc_url( $notify_url ); ?>"><?php esc_html_e( 'Copy to a new draft', 'woocommerce' ); ?></a></div>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duplicate a product action.
|
* Duplicate a product action.
|
||||||
|
@ -111,7 +107,7 @@ class WC_Admin_Duplicate_Product {
|
||||||
|
|
||||||
if ( false === $product ) {
|
if ( false === $product ) {
|
||||||
/* translators: %s: product id */
|
/* translators: %s: product id */
|
||||||
wp_die( sprintf( esc_html__( 'Product creation failed, could not find original product: %s', 'woocommerce' ), $product_id ) );
|
wp_die( sprintf( esc_html__( 'Product creation failed, could not find original product: %s', 'woocommerce' ), esc_html( $product_id ) ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
$duplicate = $this->product_duplicate( $product );
|
$duplicate = $this->product_duplicate( $product );
|
||||||
|
@ -120,7 +116,7 @@ class WC_Admin_Duplicate_Product {
|
||||||
do_action( 'woocommerce_product_duplicate', $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.' );
|
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() ) );
|
wp_redirect( admin_url( 'post.php?action=edit&post=' . $duplicate->get_id() ) );
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
@ -128,15 +124,20 @@ class WC_Admin_Duplicate_Product {
|
||||||
/**
|
/**
|
||||||
* Function to create the duplicate of the product.
|
* Function to create the duplicate of the product.
|
||||||
*
|
*
|
||||||
* @param WC_Product $product
|
* @param WC_Product $product The product to duplicate.
|
||||||
* @return WC_Product
|
* @return WC_Product The duplicate.
|
||||||
*/
|
*/
|
||||||
public function product_duplicate( $product ) {
|
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() ) );
|
$meta_to_exclude = array_filter( apply_filters( 'woocommerce_duplicate_product_exclude_meta', array() ) );
|
||||||
|
|
||||||
$duplicate = clone $product;
|
$duplicate = clone $product;
|
||||||
$duplicate->set_id( 0 );
|
$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_name( sprintf( esc_html__( '%s (Copy)', 'woocommerce' ), $duplicate->get_name() ) );
|
||||||
$duplicate->set_total_sales( 0 );
|
$duplicate->set_total_sales( 0 );
|
||||||
if ( '' !== $product->get_sku( 'edit' ) ) {
|
if ( '' !== $product->get_sku( 'edit' ) ) {
|
||||||
|
@ -153,7 +154,11 @@ class WC_Admin_Duplicate_Product {
|
||||||
$duplicate->delete_meta_data( $meta_key );
|
$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 );
|
do_action( 'woocommerce_product_duplicate_before_save', $duplicate, $product );
|
||||||
|
|
||||||
// Save parent product.
|
// Save parent product.
|
||||||
|
@ -168,6 +173,12 @@ class WC_Admin_Duplicate_Product {
|
||||||
$child_duplicate->set_id( 0 );
|
$child_duplicate->set_id( 0 );
|
||||||
$child_duplicate->set_date_created( null );
|
$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' ) ) {
|
if ( '' !== $child->get_sku( 'edit' ) ) {
|
||||||
$child_duplicate->set_sku( wc_product_generate_unique_sku( 0, $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 );
|
$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 );
|
do_action( 'woocommerce_product_duplicate_before_save', $child_duplicate, $child );
|
||||||
|
|
||||||
$child_duplicate->save();
|
$child_duplicate->save();
|
||||||
|
@ -193,7 +208,7 @@ class WC_Admin_Duplicate_Product {
|
||||||
* Get a product from the database to duplicate.
|
* Get a product from the database to duplicate.
|
||||||
*
|
*
|
||||||
* @deprecated 3.0.0
|
* @deprecated 3.0.0
|
||||||
* @param mixed $id
|
* @param mixed $id The ID of the product to duplicate.
|
||||||
* @return object|bool
|
* @return object|bool
|
||||||
* @see duplicate_product
|
* @see duplicate_product
|
||||||
*/
|
*/
|
||||||
|
@ -215,6 +230,44 @@ class WC_Admin_Duplicate_Product {
|
||||||
|
|
||||||
return $post;
|
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();
|
return new WC_Admin_Duplicate_Product();
|
||||||
|
|
|
@ -0,0 +1,110 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Unit tests for the admin product duplication class.
|
||||||
|
*
|
||||||
|
* @package WooCommerce\Tests\Admin
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WC_Tests_Admin_Duplicate_Product tests.
|
||||||
|
*
|
||||||
|
* @package WooCommerce\Tests\Admin
|
||||||
|
* @since 3.9.0
|
||||||
|
*/
|
||||||
|
class WC_Tests_Admin_Duplicate_Product extends WC_Unit_Test_Case {
|
||||||
|
/**
|
||||||
|
* Test duplication of a simple product.
|
||||||
|
*/
|
||||||
|
public function test_simple_product_duplication() {
|
||||||
|
$product = WC_Helper_Product::create_simple_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 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() );
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue