Merge pull request #25064 from woocommerce/fix/24315

Optimize variable product duplication slug generation
This commit is contained in:
Claudio Sanches 2019-12-03 15:28:55 -03:00 committed by GitHub
commit 6a395e2485
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 183 additions and 20 deletions

View File

@ -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();

View File

@ -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() );
}
}