Merge branch 'trunk' of github.com:woocommerce/woocommerce into add/plugin-upload-flow

This commit is contained in:
Greg 2021-07-19 09:06:52 -06:00
commit 3267a7253c
18 changed files with 1525 additions and 253 deletions

View File

@ -22,7 +22,7 @@
"psr/container": "1.0.0", "psr/container": "1.0.0",
"woocommerce/action-scheduler": "3.2.1", "woocommerce/action-scheduler": "3.2.1",
"woocommerce/woocommerce-admin": "2.4.1", "woocommerce/woocommerce-admin": "2.4.1",
"woocommerce/woocommerce-blocks": "5.3.1" "woocommerce/woocommerce-blocks": "5.5.1"
}, },
"require-dev": { "require-dev": {
"bamarni/composer-bin-plugin": "^1.4" "bamarni/composer-bin-plugin": "^1.4"

14
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "78971f2035da15d44d3941df88d5afbc", "content-hash": "acab5cd3f2509342ed733e770638ab4c",
"packages": [ "packages": [
{ {
"name": "automattic/jetpack-autoloader", "name": "automattic/jetpack-autoloader",
@ -584,16 +584,16 @@
}, },
{ {
"name": "woocommerce/woocommerce-blocks", "name": "woocommerce/woocommerce-blocks",
"version": "v5.3.1", "version": "v5.5.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/woocommerce/woocommerce-gutenberg-products-block.git", "url": "https://github.com/woocommerce/woocommerce-gutenberg-products-block.git",
"reference": "28c7c4f9b5cace9098fb2246ff93abe110a26bca" "reference": "f3d8dbadb745cbb2544e86dfb3864e870146d197"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/woocommerce/woocommerce-gutenberg-products-block/zipball/28c7c4f9b5cace9098fb2246ff93abe110a26bca", "url": "https://api.github.com/repos/woocommerce/woocommerce-gutenberg-products-block/zipball/f3d8dbadb745cbb2544e86dfb3864e870146d197",
"reference": "28c7c4f9b5cace9098fb2246ff93abe110a26bca", "reference": "f3d8dbadb745cbb2544e86dfb3864e870146d197",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -629,9 +629,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues", "issues": "https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues",
"source": "https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/v5.3.1" "source": "https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/v5.5.1"
}, },
"time": "2021-06-15T09:12:48+00:00" "time": "2021-07-14T20:59:04+00:00"
} }
], ],
"packages-dev": [ "packages-dev": [

View File

@ -9,6 +9,8 @@ if ( ! defined( 'ABSPATH' ) ) {
exit; exit;
} }
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore as ProductAttributesLookupDataStore;
/** /**
* Legacy product contains all deprecated methods for this class and can be * Legacy product contains all deprecated methods for this class and can be
* removed in the future. * removed in the future.
@ -1374,13 +1376,22 @@ class WC_Product extends WC_Abstract_Legacy_Product {
*/ */
do_action( 'woocommerce_before_' . $this->object_type . '_object_save', $this, $this->data_store ); do_action( 'woocommerce_before_' . $this->object_type . '_object_save', $this, $this->data_store );
$state = $this->before_data_store_save_or_update();
if ( $this->get_id() ) { if ( $this->get_id() ) {
$changeset = $this->get_changes();
$this->data_store->update( $this ); $this->data_store->update( $this );
} else { } else {
$changeset = null;
$this->data_store->create( $this ); $this->data_store->create( $this );
} }
$this->maybe_defer_product_sync(); $this->after_data_store_save_or_update( $state );
// Update attributes lookup table if the product is new OR it's not but there are actually any changes.
if ( is_null( $changeset ) || ! empty( $changeset ) ) {
wc_get_container()->get( ProductAttributesLookupDataStore::class )->on_product_changed( $this, $changeset );
}
/** /**
* Trigger action after saving to the DB. * Trigger action after saving to the DB.
@ -1393,6 +1404,25 @@ class WC_Product extends WC_Abstract_Legacy_Product {
return $this->get_id(); return $this->get_id();
} }
/**
* Do any extra processing needed before the actual product save
* (but after triggering the 'woocommerce_before_..._object_save' action)
*
* @return mixed A state value that will be passed to after_data_store_save_or_update.
*/
protected function before_data_store_save_or_update() {
}
/**
* Do any extra processing needed after the actual product save
* (but before triggering the 'woocommerce_after_..._object_save' action)
*
* @param mixed $state The state object that was returned by before_data_store_save_or_update.
*/
protected function after_data_store_save_or_update( $state ) {
$this->maybe_defer_product_sync();
}
/** /**
* Delete the product, set its ID to 0, and return result. * Delete the product, set its ID to 0, and return result.
* *
@ -1400,10 +1430,12 @@ class WC_Product extends WC_Abstract_Legacy_Product {
* @return bool result * @return bool result
*/ */
public function delete( $force_delete = false ) { public function delete( $force_delete = false ) {
$deleted = parent::delete( $force_delete ); $product_id = $this->get_id();
$deleted = parent::delete( $force_delete );
if ( $deleted ) { if ( $deleted ) {
$this->maybe_defer_product_sync(); $this->maybe_defer_product_sync();
wc_get_container()->get( ProductAttributesLookupDataStore::class )->on_product_deleted( $product_id );
} }
return $deleted; return $deleted;

View File

@ -11,22 +11,44 @@ if ( ! defined( 'ABSPATH' ) ) {
exit; exit;
} }
foreach ( $tools as $action_name => $tool ) {
?>
<form id="<?php echo esc_attr( 'form_' . $action_name ); ?>" method="GET" action="<?php echo esc_attr( esc_url( admin_url( 'admin.php?foo=bar' ) ) ); ?>">
<?php wp_nonce_field( 'debug_action', '_wpnonce', false ); ?>
<input type="hidden" name="page" value="wc-status"/>
<input type="hidden" name="tab" value="tools"/>
<input type="hidden" name="action" value="<?php echo esc_attr( $action_name ); ?>"/>
</form>
<?php
}
?> ?>
<form method="post" action="options.php">
<?php settings_fields( 'woocommerce_status_settings_fields' ); ?> <table class="wc_status_table wc_status_table--tools widefat" cellspacing="0">
<table class="wc_status_table wc_status_table--tools widefat" cellspacing="0"> <tbody class="tools">
<tbody class="tools"> <?php foreach ( $tools as $action_name => $tool ) : ?>
<?php foreach ( $tools as $action_name => $tool ) : ?> <tr class="<?php echo sanitize_html_class( $action_name ); ?>">
<tr class="<?php echo sanitize_html_class( $action_name ); ?>"> <th>
<th> <strong class="name"><?php echo esc_html( $tool['name'] ); ?></strong>
<strong class="name"><?php echo esc_html( $tool['name'] ); ?></strong> <p class="description">
<p class="description"><?php echo wp_kses_post( $tool['desc'] ); ?></p> <?php
</th> echo wp_kses_post( $tool['desc'] );
<td class="run-tool"> if ( ! is_null( ArrayUtil::get_value_or_default( $tool, 'selector' ) ) ) {
<a <?php echo ArrayUtil::is_truthy( $tool, 'disabled' ) ? 'disabled' : ''; ?> href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin.php?page=wc-status&tab=tools&action=' . $action_name ), 'debug_action' ) ); ?>" class="button button-large <?php echo esc_attr( $action_name ); ?>"><?php echo esc_html( $tool['button'] ); ?></a> $selector = $tool['selector'];
</td> if ( isset( $selector['description'] ) ) {
</tr> echo '</p><p class="description">';
<?php endforeach; ?> echo wp_kses_post( $selector['description'] );
</tbody> }
</table> // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
</form> echo "&nbsp;&nbsp;<select style='width: 300px;' form='form_$action_name' id='selector_$action_name' data-allow_clear='true' class='${selector['class']}' name='${selector['name']}' data-placeholder='${selector['placeholder']}' data-action='${selector['search_action']}'></select>";
}
?>
</p>
</th>
<td class="run-tool">
<?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<input <?php echo ArrayUtil::is_truthy( $tool, 'disabled' ) ? 'disabled' : ''; ?> type="submit" form="<?php echo 'form_' . $action_name; ?>" class="button button-large" value="<?php echo esc_attr( $tool['button'] ); ?>" />
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>

View File

@ -8,6 +8,9 @@
* @version 2.2.0 * @version 2.2.0
*/ */
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore as ProductAttributesLookupDataStore;
use Automattic\WooCommerce\Proxies\LegacyProxy;
defined( 'ABSPATH' ) || exit; defined( 'ABSPATH' ) || exit;
/** /**
@ -296,27 +299,31 @@ class WC_Post_Data {
* @param mixed $id ID of post being deleted. * @param mixed $id ID of post being deleted.
*/ */
public static function delete_post( $id ) { public static function delete_post( $id ) {
if ( ! current_user_can( 'delete_posts' ) || ! $id ) { $container = wc_get_container();
if ( ! $container->get( LegacyProxy::class )->call_function( 'current_user_can', 'delete_posts' ) || ! $id ) {
return; return;
} }
$post_type = get_post_type( $id ); $post_type = self::get_post_type( $id );
switch ( $post_type ) { switch ( $post_type ) {
case 'product': case 'product':
$data_store = WC_Data_Store::load( 'product-variable' ); $data_store = WC_Data_Store::load( 'product-variable' );
$data_store->delete_variations( $id, true ); $data_store->delete_variations( $id, true );
$data_store->delete_from_lookup_table( $id, 'wc_product_meta_lookup' ); $data_store->delete_from_lookup_table( $id, 'wc_product_meta_lookup' );
$parent_id = wp_get_post_parent_id( $id ); $container->get( ProductAttributesLookupDataStore::class )->on_product_deleted( $id );
$parent_id = wp_get_post_parent_id( $id );
if ( $parent_id ) { if ( $parent_id ) {
wc_delete_product_transients( $parent_id ); wc_delete_product_transients( $parent_id );
} }
break; break;
case 'product_variation': case 'product_variation':
$data_store = WC_Data_Store::load( 'product' ); $data_store = WC_Data_Store::load( 'product' );
$data_store->delete_from_lookup_table( $id, 'wc_product_meta_lookup' ); $data_store->delete_from_lookup_table( $id, 'wc_product_meta_lookup' );
wc_delete_product_transients( wp_get_post_parent_id( $id ) ); wc_delete_product_transients( wp_get_post_parent_id( $id ) );
$container->get( ProductAttributesLookupDataStore::class )->on_product_deleted( $id );
break; break;
case 'shop_order': case 'shop_order':
global $wpdb; global $wpdb;
@ -342,7 +349,7 @@ class WC_Post_Data {
return; return;
} }
$post_type = get_post_type( $id ); $post_type = self::get_post_type( $id );
// If this is an order, trash any refunds too. // If this is an order, trash any refunds too.
if ( in_array( $post_type, wc_get_order_types( 'order-count' ), true ) ) { if ( in_array( $post_type, wc_get_order_types( 'order-count' ), true ) ) {
@ -360,6 +367,9 @@ class WC_Post_Data {
} elseif ( 'product' === $post_type ) { } elseif ( 'product' === $post_type ) {
$data_store = WC_Data_Store::load( 'product-variable' ); $data_store = WC_Data_Store::load( 'product-variable' );
$data_store->delete_variations( $id, false ); $data_store->delete_variations( $id, false );
wc_get_container()->get( ProductAttributesLookupDataStore::class )->on_product_deleted( $id );
} elseif ( 'product_variation' === $post_type ) {
wc_get_container()->get( ProductAttributesLookupDataStore::class )->on_product_deleted( $id );
} }
} }
@ -373,7 +383,7 @@ class WC_Post_Data {
return; return;
} }
$post_type = get_post_type( $id ); $post_type = self::get_post_type( $id );
if ( in_array( $post_type, wc_get_order_types( 'order-count' ), true ) ) { if ( in_array( $post_type, wc_get_order_types( 'order-count' ), true ) ) {
global $wpdb; global $wpdb;
@ -391,9 +401,23 @@ class WC_Post_Data {
$data_store->untrash_variations( $id ); $data_store->untrash_variations( $id );
wc_product_force_unique_sku( $id ); wc_product_force_unique_sku( $id );
wc_get_container()->get( ProductAttributesLookupDataStore::class )->on_product_changed( $id );
} elseif ( 'product_variation' === $post_type ) {
wc_get_container()->get( ProductAttributesLookupDataStore::class )->on_product_changed( $id );
} }
} }
/**
* Get the post type for a given post.
*
* @param int $id The post id.
* @return string The post type.
*/
private static function get_post_type( $id ) {
return wc_get_container()->get( LegacyProxy::class )->call_function( 'get_post_type', $id );
}
/** /**
* Before deleting an order, do some cleanup. * Before deleting an order, do some cleanup.
* *

View File

@ -449,47 +449,31 @@ class WC_Product_Variable extends WC_Product {
} }
/** /**
* Save data (either create or update depending on if we are working on an existing product). * Do any extra processing needed before the actual product save
* (but after triggering the 'woocommerce_before_..._object_save' action)
* *
* @since 3.0.0 * @return mixed A state value that will be passed to after_data_store_save_or_update.
*/ */
public function save() { protected function before_data_store_save_or_update() {
$this->validate_props();
if ( ! $this->data_store ) {
return $this->get_id();
}
/**
* Trigger action before saving to the DB. Allows you to adjust object props before save.
*
* @param WC_Data $this The object being saved.
* @param WC_Data_Store_WP $data_store The data store persisting the data.
*/
do_action( 'woocommerce_before_' . $this->object_type . '_object_save', $this, $this->data_store );
// Get names before save. // Get names before save.
$previous_name = $this->data['name']; $previous_name = $this->data['name'];
$new_name = $this->get_name( 'edit' ); $new_name = $this->get_name( 'edit' );
if ( $this->get_id() ) { return array(
$this->data_store->update( $this ); 'previous_name' => $previous_name,
} else { 'new_name' => $new_name,
$this->data_store->create( $this ); );
} }
$this->data_store->sync_variation_names( $this, $previous_name, $new_name ); /**
* Do any extra processing needed after the actual product save
* (but before triggering the 'woocommerce_after_..._object_save' action)
*
* @param mixed $state The state object that was returned by before_data_store_save_or_update.
*/
protected function after_data_store_save_or_update( $state ) {
$this->data_store->sync_variation_names( $this, $state['previous_name'], $state['new_name'] );
$this->data_store->sync_managed_variation_stock_status( $this ); $this->data_store->sync_managed_variation_stock_status( $this );
/**
* Trigger action after saving to the DB.
*
* @param WC_Data $this The object being saved.
* @param WC_Data_Store_WP $data_store The data store persisting the data.
*/
do_action( 'woocommerce_after_' . $this->object_type . '_object_save', $this, $this->data_store );
return $this->get_id();
} }
/* /*

View File

@ -583,21 +583,4 @@ class WC_Product_Variation extends WC_Product_Simple {
return $valid_classes; return $valid_classes;
} }
/**
* Delete variation, set the ID to 0, and return result.
*
* @since 4.4.0
* @param bool $force_delete Should the variation be deleted permanently.
* @return bool result
*/
public function delete( $force_delete = false ) {
$variation_id = $this->get_id();
if ( ! parent::delete( $force_delete ) ) {
return false;
}
return true;
}
} }

View File

@ -8,10 +8,11 @@
defined( 'ABSPATH' ) || exit; defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\DownloadPermissionsAdjuster;
use Automattic\WooCommerce\Internal\RestockRefundedItemsAdjuster;
use Automattic\WooCommerce\Internal\AssignDefaultCategory; use Automattic\WooCommerce\Internal\AssignDefaultCategory;
use Automattic\WooCommerce\Internal\DownloadPermissionsAdjuster;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator; use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
use Automattic\WooCommerce\Internal\RestockRefundedItemsAdjuster;
use Automattic\WooCommerce\Proxies\LegacyProxy; use Automattic\WooCommerce\Proxies\LegacyProxy;
/** /**
@ -213,6 +214,7 @@ final class WooCommerce {
wc_get_container()->get( DownloadPermissionsAdjuster::class ); wc_get_container()->get( DownloadPermissionsAdjuster::class );
wc_get_container()->get( AssignDefaultCategory::class ); wc_get_container()->get( AssignDefaultCategory::class );
wc_get_container()->get( DataRegenerator::class ); wc_get_container()->get( DataRegenerator::class );
wc_get_container()->get( LookupDataStore::class );
wc_get_container()->get( RestockRefundedItemsAdjuster::class ); wc_get_container()->get( RestockRefundedItemsAdjuster::class );
} }

View File

@ -1118,7 +1118,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
$product->get_id() $product->get_id()
); );
$query .= ' AND postmeta.meta_key IN ( "' . implode( '","', array_map( 'esc_sql', $meta_attribute_names ) ) . '" )'; $query .= " AND postmeta.meta_key IN ( '" . implode( "','", array_map( 'esc_sql', $meta_attribute_names ) ) . "' )";
$query .= ' ORDER BY posts.menu_order ASC, postmeta.post_id ASC;'; $query .= ' ORDER BY posts.menu_order ASC, postmeta.post_id ASC;';

View File

@ -277,7 +277,7 @@ class WC_Webhook_Data_Store implements WC_Webhook_Data_Store_Interface {
$limit = -1 < $args['limit'] ? $wpdb->prepare( 'LIMIT %d', $args['limit'] ) : ''; $limit = -1 < $args['limit'] ? $wpdb->prepare( 'LIMIT %d', $args['limit'] ) : '';
$offset = 0 < $args['offset'] ? $wpdb->prepare( 'OFFSET %d', $args['offset'] ) : ''; $offset = 0 < $args['offset'] ? $wpdb->prepare( 'OFFSET %d', $args['offset'] ) : '';
$status = ! empty( $args['status'] ) ? $wpdb->prepare( 'AND `status` = %s', isset( $statuses[ $args['status'] ] ) ? $statuses[ $args['status'] ] : $args['status'] ) : ''; $status = ! empty( $args['status'] ) ? $wpdb->prepare( 'AND `status` = %s', isset( $statuses[ $args['status'] ] ) ? $statuses[ $args['status'] ] : $args['status'] ) : '';
$search = ! empty( $args['search'] ) ? "AND `name` LIKE '%" . $wpdb->esc_like( sanitize_text_field( $args['search'] ) ) . "%'" : ''; $search = ! empty( $args['search'] ) ? $wpdb->prepare( 'AND `name` LIKE %s', '%' . $wpdb->esc_like( sanitize_text_field( $args['search'] ) ) . '%' ) : '';
$include = ''; $include = '';
$exclude = ''; $exclude = '';
$date_created = ''; $date_created = '';

View File

@ -9,7 +9,6 @@ use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator; use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\Filterer; use Automattic\WooCommerce\Internal\ProductAttributesLookup\Filterer;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore; use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
use Automattic\WooCommerce\Proxies\LegacyProxy;
/** /**
* Service provider for the ProductAttributesLookupServiceProvider namespace. * Service provider for the ProductAttributesLookupServiceProvider namespace.
@ -32,7 +31,7 @@ class ProductAttributesLookupServiceProvider extends AbstractServiceProvider {
*/ */
public function register() { public function register() {
$this->share( DataRegenerator::class )->addArgument( LookupDataStore::class ); $this->share( DataRegenerator::class )->addArgument( LookupDataStore::class );
$this->share( Filterer::class )->addArgument( LookupDataStore::class )->addArgument( LegacyProxy::class ); $this->share( Filterer::class )->addArgument( LookupDataStore::class );
$this->share( LookupDataStore::class ); $this->share( LookupDataStore::class );
} }
} }

View File

@ -6,6 +6,7 @@
namespace Automattic\WooCommerce\Internal\ProductAttributesLookup; namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore; use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
use Automattic\WooCommerce\Utilities\ArrayUtil;
defined( 'ABSPATH' ) || exit; defined( 'ABSPATH' ) || exit;
@ -61,7 +62,7 @@ class DataRegenerator {
); );
add_action( add_action(
'woocommerce_run_product_attribute_lookup_update_callback', 'woocommerce_run_product_attribute_lookup_regeneration_callback',
function () { function () {
$this->run_regeneration_step_callback(); $this->run_regeneration_step_callback();
} }
@ -93,6 +94,8 @@ class DataRegenerator {
* (Note how we are returning "false" since the class handles the step scheduling by itself). * (Note how we are returning "false" since the class handles the step scheduling by itself).
*/ */
public function initiate_regeneration() { public function initiate_regeneration() {
$this->enable_or_disable_lookup_table_usage( false );
$this->delete_all_attributes_lookup_data(); $this->delete_all_attributes_lookup_data();
$products_exist = $this->initialize_table_and_data(); $products_exist = $this->initialize_table_and_data();
if ( $products_exist ) { if ( $products_exist ) {
@ -102,15 +105,6 @@ class DataRegenerator {
} }
} }
/**
* Tells if a regeneration is already in progress.
*
* @return bool True if a regeneration is already in progress.
*/
public function regeneration_is_in_progress() {
return ! is_null( get_option( 'woocommerce_attribute_lookup__last_products_page_processed', null ) );
}
/** /**
* Delete all the existing data related to the lookup table, including the table itself. * Delete all the existing data related to the lookup table, including the table itself.
* *
@ -124,6 +118,7 @@ class DataRegenerator {
delete_option( 'woocommerce_attribute_lookup__enabled' ); delete_option( 'woocommerce_attribute_lookup__enabled' );
delete_option( 'woocommerce_attribute_lookup__last_product_id_to_process' ); delete_option( 'woocommerce_attribute_lookup__last_product_id_to_process' );
delete_option( 'woocommerce_attribute_lookup__last_products_page_processed' ); delete_option( 'woocommerce_attribute_lookup__last_products_page_processed' );
$this->data_store->unset_regeneration_in_progress_flag();
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( 'DROP TABLE IF EXISTS ' . $this->lookup_table_name ); $wpdb->query( 'DROP TABLE IF EXISTS ' . $this->lookup_table_name );
@ -170,6 +165,7 @@ CREATE TABLE ' . $this->lookup_table_name . '(
return false; return false;
} }
$this->data_store->set_regeneration_in_progress_flag();
update_option( 'woocommerce_attribute_lookup__last_product_id_to_process', current( $last_existing_product_id ) ); update_option( 'woocommerce_attribute_lookup__last_product_id_to_process', current( $last_existing_product_id ) );
update_option( 'woocommerce_attribute_lookup__last_products_page_processed', 0 ); update_option( 'woocommerce_attribute_lookup__last_products_page_processed', 0 );
@ -181,7 +177,7 @@ CREATE TABLE ' . $this->lookup_table_name . '(
* schedules the next step if necessary. * schedules the next step if necessary.
*/ */
private function run_regeneration_step_callback() { private function run_regeneration_step_callback() {
if ( ! $this->regeneration_is_in_progress() ) { if ( ! $this->data_store->regeneration_is_in_progress() ) {
return; return;
} }
@ -200,7 +196,7 @@ CREATE TABLE ' . $this->lookup_table_name . '(
$queue = WC()->get_instance_of( \WC_Queue::class ); $queue = WC()->get_instance_of( \WC_Queue::class );
$queue->schedule_single( $queue->schedule_single(
WC()->call_function( 'time' ) + 1, WC()->call_function( 'time' ) + 1,
'woocommerce_run_product_attribute_lookup_update_callback', 'woocommerce_run_product_attribute_lookup_regeneration_callback',
array(), array(),
'woocommerce-db-updates' 'woocommerce-db-updates'
); );
@ -233,7 +229,7 @@ CREATE TABLE ' . $this->lookup_table_name . '(
} }
foreach ( $product_ids as $id ) { foreach ( $product_ids as $id ) {
$this->data_store->update_data_for_product( $id ); $this->data_store->create_data_for_product( $id );
} }
update_option( 'woocommerce_attribute_lookup__last_products_page_processed', $current_products_page ); update_option( 'woocommerce_attribute_lookup__last_products_page_processed', $current_products_page );
@ -249,19 +245,7 @@ CREATE TABLE ' . $this->lookup_table_name . '(
delete_option( 'woocommerce_attribute_lookup__last_product_id_to_process' ); delete_option( 'woocommerce_attribute_lookup__last_product_id_to_process' );
delete_option( 'woocommerce_attribute_lookup__last_products_page_processed' ); delete_option( 'woocommerce_attribute_lookup__last_products_page_processed' );
update_option( 'woocommerce_attribute_lookup__enabled', 'no' ); update_option( 'woocommerce_attribute_lookup__enabled', 'no' );
} $this->data_store->unset_regeneration_in_progress_flag();
/**
* Check if the lookup table exists in the database.
*
* @return bool True if the lookup table exists in the database.
*/
private function lookup_table_exists() {
global $wpdb;
$query = $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $this->lookup_table_name ) );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return $this->lookup_table_name === $wpdb->get_var( $query );
} }
/** /**
@ -275,14 +259,14 @@ CREATE TABLE ' . $this->lookup_table_name . '(
return $tools_array; return $tools_array;
} }
$lookup_table_exists = $this->lookup_table_exists(); $lookup_table_exists = $this->data_store->check_lookup_table_exists();
$generation_is_in_progress = $this->regeneration_is_in_progress(); $generation_is_in_progress = $this->data_store->regeneration_is_in_progress();
// Regenerate table. // Regenerate table.
if ( $lookup_table_exists ) { if ( $lookup_table_exists ) {
$generate_item_name = __( 'Regenerate the product attributes lookup table', 'woocommerce' ); $generate_item_name = __( 'Regenerate the product attributes lookup table', 'woocommerce' );
$generate_item_desc = __( 'This tool will regenerate the product attributes lookup table data from existing products data. This process may take a while.', 'woocommerce' ); $generate_item_desc = __( 'This tool will regenerate the product attributes lookup table data from existing product(s) data. This process may take a while.', 'woocommerce' );
$generate_item_return = __( 'Product attributes lookup table data is regenerating', 'woocommerce' ); $generate_item_return = __( 'Product attributes lookup table data is regenerating', 'woocommerce' );
$generate_item_button = __( 'Regenerate', 'woocommerce' ); $generate_item_button = __( 'Regenerate', 'woocommerce' );
} else { } else {
@ -302,6 +286,16 @@ CREATE TABLE ' . $this->lookup_table_name . '(
}, },
); );
if ( $lookup_table_exists ) {
$entry['selector'] = array(
'description' => __( 'Select a product to regenerate the data for, or leave empty for a full table regeneration:', 'woocommerce' ),
'class' => 'wc-product-search',
'search_action' => 'woocommerce_json_search_products',
'name' => 'regenerate_product_attribute_lookup_data_product_id',
'placeholder' => esc_attr__( 'Search for a product&hellip;', 'woocommerce' ),
);
}
if ( $generation_is_in_progress ) { if ( $generation_is_in_progress ) {
$entry['button'] = sprintf( $entry['button'] = sprintf(
/* translators: %d: How many products have been processed so far. */ /* translators: %d: How many products have been processed so far. */
@ -335,35 +329,6 @@ CREATE TABLE ' . $this->lookup_table_name . '(
); );
} }
if ( $lookup_table_exists && ! $generation_is_in_progress ) {
// Enable or disable table usage.
if ( 'yes' === get_option( 'woocommerce_attribute_lookup__enabled' ) ) {
$tools_array['disable_product_attributes_lookup_table_usage'] = array(
'name' => __( 'Disable the product attributes lookup table usage', 'woocommerce' ),
'desc' => __( 'The product attributes lookup table usage is currently enabled, use this tool to disable it.', 'woocommerce' ),
'button' => __( 'Disable', 'woocommerce' ),
'requires_refresh' => true,
'callback' => function () {
$this->enable_or_disable_lookup_table_usage( false );
return __( 'Product attributes lookup table usage has been disabled.', 'woocommerce' );
},
);
} else {
$tools_array['enable_product_attributes_lookup_table_usage'] = array(
'name' => __( 'Enable the product attributes lookup table usage', 'woocommerce' ),
'desc' => __( 'The product attributes lookup table usage is currently disabled, use this tool to enable it.', 'woocommerce' ),
'button' => __( 'Enable', 'woocommerce' ),
'requires_refresh' => true,
'callback' => function () {
$this->enable_or_disable_lookup_table_usage( true );
return __( 'Product attributes lookup table usage has been enabled.', 'woocommerce' );
},
);
}
}
return $tools_array; return $tools_array;
} }
@ -373,11 +338,19 @@ CREATE TABLE ' . $this->lookup_table_name . '(
* @throws \Exception The regeneration is already in progress. * @throws \Exception The regeneration is already in progress.
*/ */
private function initiate_regeneration_from_tools_page() { private function initiate_regeneration_from_tools_page() {
if ( $this->regeneration_is_in_progress() ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
throw new \Exception( 'Product attributes lookup table is already regenerating.' ); if ( ! isset( $_REQUEST['_wpnonce'] ) || false === wp_verify_nonce( $_REQUEST['_wpnonce'], 'debug_action' ) ) {
throw new \Exception( 'Invalid nonce' );
} }
$this->initiate_regeneration(); if ( isset( $_REQUEST['regenerate_product_attribute_lookup_data_product_id'] ) ) {
$product_id = (int) $_REQUEST['regenerate_product_attribute_lookup_data_product_id'];
$this->check_can_do_lookup_table_regeneration( $product_id );
$this->data_store->create_data_for_product( $product_id );
} else {
$this->check_can_do_lookup_table_regeneration();
$this->initiate_regeneration();
}
} }
/** /**
@ -387,10 +360,32 @@ CREATE TABLE ' . $this->lookup_table_name . '(
* @throws \Exception A lookup table regeneration is currently in progress. * @throws \Exception A lookup table regeneration is currently in progress.
*/ */
private function enable_or_disable_lookup_table_usage( $enable ) { private function enable_or_disable_lookup_table_usage( $enable ) {
if ( $this->regeneration_is_in_progress() ) { if ( $this->data_store->regeneration_is_in_progress() ) {
throw new \Exception( "Can't enable or disable the attributes lookup table usage while it's regenerating." ); throw new \Exception( "Can't enable or disable the attributes lookup table usage while it's regenerating." );
} }
update_option( 'woocommerce_attribute_lookup__enabled', $enable ? 'yes' : 'no' ); update_option( 'woocommerce_attribute_lookup__enabled', $enable ? 'yes' : 'no' );
} }
/**
* Check if everything is good to go to perform a per product lookup table data regeneration
* and throw an exception if not.
*
* @param mixed $product_id The product id to check the regeneration viability for, or null to skip product check.
* @throws \Exception Something prevents the regeneration from starting.
*/
private function check_can_do_lookup_table_regeneration( $product_id = null ) {
if ( ! $this->data_store->is_feature_visible() ) {
throw new \Exception( "Can't do product attribute lookup data regeneration: feature is not visible" );
}
if ( ! $this->data_store->check_lookup_table_exists() ) {
throw new \Exception( "Can't do product attribute lookup data regeneration: lookup table doesn't exist" );
}
if ( $this->data_store->regeneration_is_in_progress() ) {
throw new \Exception( "Can't do product attribute lookup data regeneration: regeneration is already in progress" );
}
if ( $product_id && ! wc_get_product( $product_id ) ) {
throw new \Exception( "Can't do product attribute lookup data regeneration: product doesn't exist" );
}
}
} }

View File

@ -196,7 +196,9 @@ class Filterer {
$query['select'] = 'SELECT COUNT(DISTINCT product_or_parent_id) as term_count, term_id as term_count_id'; $query['select'] = 'SELECT COUNT(DISTINCT product_or_parent_id) as term_count, term_id as term_count_id';
$query['from'] = "FROM {$this->lookup_table_name}"; $query['from'] = "FROM {$this->lookup_table_name}";
$query['join'] = "INNER JOIN {$wpdb->posts} ON {$wpdb->posts}.ID = {$this->lookup_table_name}.product_or_parent_id"; $query['join'] = "
{$tax_query_sql['join']} {$meta_query_sql['join']}
INNER JOIN {$wpdb->posts} ON {$wpdb->posts}.ID = {$this->lookup_table_name}.product_or_parent_id";
$term_ids_sql = $this->get_term_ids_sql( $term_ids ); $term_ids_sql = $this->get_term_ids_sql( $term_ids );
$query['where'] = " $query['where'] = "
@ -211,42 +213,51 @@ class Filterer {
$attributes_to_filter_by = \WC_Query::get_layered_nav_chosen_attributes(); $attributes_to_filter_by = \WC_Query::get_layered_nav_chosen_attributes();
if ( ! empty( $attributes_to_filter_by ) ) { if ( ! empty( $attributes_to_filter_by ) ) {
$all_terms_to_filter_by = array(); $and_term_ids = array();
foreach ( $attributes_to_filter_by as $taxonomy => $data ) { $or_term_ids = array();
$all_terms = get_terms( $taxonomy, array( 'hide_empty' => false ) );
$term_ids_by_slug = wp_list_pluck( $all_terms, 'term_id', 'slug' );
$term_ids_to_filter_by = array_values( array_intersect_key( $term_ids_by_slug, array_flip( $data['terms'] ) ) );
$all_terms_to_filter_by = array_merge( $all_terms_to_filter_by, $term_ids_to_filter_by );
$term_ids_to_filter_by_list = '(' . join( ',', $term_ids_to_filter_by ) . ')';
$count = count( $term_ids_to_filter_by ); foreach ( $attributes_to_filter_by as $taxonomy => $data ) {
if ( 0 !== $count ) { $all_terms = get_terms( $taxonomy, array( 'hide_empty' => false ) );
$query['where'] .= ' AND product_or_parent_id IN ('; $term_ids_by_slug = wp_list_pluck( $all_terms, 'term_id', 'slug' );
if ( 'and' === $attributes_to_filter_by[ $taxonomy ]['query_type'] ) { $term_ids_to_filter_by = array_values( array_intersect_key( $term_ids_by_slug, array_flip( $data['terms'] ) ) );
$query['where'] .= " if ( 'and' === $data['query_type'] ) {
SELECT product_or_parent_id $and_term_ids = array_merge( $and_term_ids, $term_ids_to_filter_by );
FROM {$this->lookup_table_name} lt } else {
WHERE is_variation_attribute=0 $or_term_ids = array_merge( $or_term_ids, $term_ids_to_filter_by );
{$in_stock_clause}
AND term_id in {$term_ids_to_filter_by_list}
GROUP BY product_id
HAVING COUNT(product_id)={$count}
UNION
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE is_variation_attribute=1
{$in_stock_clause}
AND term_id in {$term_ids_to_filter_by_list}
)";
} else {
$query['where'] .= "
SELECT product_or_parent_id FROM {$this->lookup_table_name}
WHERE term_id in {$term_ids_to_filter_by_list}
{$in_stock_clause}
)";
}
} }
} }
if ( ! empty( $and_term_ids ) ) {
$terms_count = count( $and_term_ids );
$term_ids_list = '(' . join( ',', $and_term_ids ) . ')';
$query['where'] .= "
AND product_or_parent_id IN (
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE is_variation_attribute=0
{$in_stock_clause}
AND term_id in {$term_ids_list}
GROUP BY product_id
HAVING COUNT(product_id)={$terms_count}
UNION
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE is_variation_attribute=1
{$in_stock_clause}
AND term_id in {$term_ids_list}
)";
}
if ( ! empty( $or_term_ids ) ) {
$term_ids_list = '(' . join( ',', $or_term_ids ) . ')';
$query['where'] .= "
AND product_or_parent_id IN (
SELECT product_or_parent_id FROM {$this->lookup_table_name}
WHERE term_id in {$term_ids_list}
{$in_stock_clause}
)";
}
} else { } else {
$query['where'] .= $in_stock_clause; $query['where'] .= $in_stock_clause;
} }

View File

@ -14,6 +14,15 @@ defined( 'ABSPATH' ) || exit;
*/ */
class LookupDataStore { class LookupDataStore {
/**
* Types of updates to perform depending on the current changest
*/
const ACTION_NONE = 0;
const ACTION_INSERT = 1;
const ACTION_UPDATE_STOCK = 2;
const ACTION_DELETE = 3;
/** /**
* The lookup table name. * The lookup table name.
* *
@ -36,6 +45,101 @@ class LookupDataStore {
$this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup'; $this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup';
$this->is_feature_visible = false; $this->is_feature_visible = false;
$this->init_hooks();
}
/**
* Initialize the hooks used by the class.
*/
private function init_hooks() {
add_action(
'woocommerce_run_product_attribute_lookup_update_callback',
function ( $product_id, $action ) {
$this->run_update_callback( $product_id, $action );
},
10,
2
);
add_filter(
'woocommerce_get_sections_products',
function ( $products ) {
if ( $this->is_feature_visible() && $this->check_lookup_table_exists() ) {
$products['advanced'] = __( 'Advanced', 'woocommerce' );
}
return $products;
},
100,
1
);
add_filter(
'woocommerce_get_settings_products',
function ( $settings, $section_id ) {
if ( 'advanced' === $section_id && $this->is_feature_visible() && $this->check_lookup_table_exists() ) {
$title_item = array(
'title' => __( 'Product attributes lookup table', 'woocommerce' ),
'type' => 'title',
);
$regeneration_is_in_progress = $this->regeneration_is_in_progress();
if ( $regeneration_is_in_progress ) {
$title_item['desc'] = __( 'These settings are not available while the lookup table regeneration is in progress.', 'woocommerce' );
}
$settings[] = $title_item;
if ( ! $regeneration_is_in_progress ) {
$settings[] = array(
'title' => __( 'Enable table usage', 'woocommerce' ),
'desc' => __( 'Use the product attributes lookup table for catalog filtering.', 'woocommerce' ),
'id' => 'woocommerce_attribute_lookup__enable',
'default' => 'no',
'type' => 'checkbox',
'checkboxgroup' => 'start',
);
$settings[] = array(
'title' => __( 'Direct updates', 'woocommerce' ),
'desc' => __( 'Update the table directly upon product changes, instead of scheduling a deferred update.', 'woocommerce' ),
'id' => 'woocommerce_attribute_lookup__direct_updates',
'default' => 'no',
'type' => 'checkbox',
'checkboxgroup' => 'start',
);
}
$settings[] = array( 'type' => 'sectionend' );
}
return $settings;
},
100,
2
);
}
/**
* Check if the lookup table exists in the database.
*
* TODO: Remove this method and references to it once the lookup table is created via data migration.
*
* @return bool
*/
public function check_lookup_table_exists() {
global $wpdb;
$query = $wpdb->prepare(
'SELECT count(*)
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = %s;',
$this->lookup_table_name
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return (bool) $wpdb->get_var( $query );
} }
/** /**
@ -71,45 +175,229 @@ class LookupDataStore {
} }
/** /**
* Insert or update the lookup data for a given product or variation. * Insert/update the appropriate lookup table entries for a new or modified product or variation.
* If a variable product is passed the information is updated for all of its variations. * This must be invoked after a product or a variation is created (including untrashing and duplication)
* or modified.
* *
* @param int|WC_Product $product Product object or id. * @param int|\WC_Product $product Product object or product id.
* @throws \Exception A variation object is passed. * @param null|array $changeset Changes as provided by 'get_changes' method in the product object, null if it's being created.
*/ */
public function update_data_for_product( $product ) { public function on_product_changed( $product, $changeset = null ) {
// TODO: For now data is always deleted and fully regenerated, existing data should be updated instead. if ( ! $this->check_lookup_table_exists() ) {
return;
}
if ( ! is_a( $product, \WC_Product::class ) ) { if ( ! is_a( $product, \WC_Product::class ) ) {
$product = WC()->call_function( 'wc_get_product', $product ); $product = WC()->call_function( 'wc_get_product', $product );
} }
if ( $this->is_variation( $product ) ) { $action = $this->get_update_action( $changeset );
throw new \Exception( "LookupDataStore::update_data_for_product can't be called for variations." ); if ( self::ACTION_NONE !== $action ) {
} $this->maybe_schedule_update( $product->get_id(), $action );
$this->delete_lookup_table_entries_for( $product->get_id() );
if ( $this->is_variable_product( $product ) ) {
$this->create_lookup_table_entries_for_variable_product( $product );
} else {
$this->create_lookup_table_entries_for_simple_product( $product );
} }
} }
/** /**
* Delete all the lookup table entries for a given product * Schedule an update of the product attributes lookup table for a given product.
* (entries are identified by the "parent_or_product_id" field) * If an update for the same action is already scheduled, nothing is done.
*
* If the 'woocommerce_attribute_lookup__direct_update' option is set to 'yes',
* the update is done directly, without scheduling.
*
* @param int $product_id The product id to schedule the update for.
* @param int $action The action to perform, one of the ACTION_ constants.
*/
private function maybe_schedule_update( int $product_id, int $action ) {
if ( 'yes' === get_option( 'woocommerce_attribute_lookup__direct_updates' ) ) {
$this->run_update_callback( $product_id, $action );
return;
}
$args = array( $product_id, $action );
$queue = WC()->get_instance_of( \WC_Queue::class );
$already_scheduled = $queue->search(
array(
'hook' => 'woocommerce_run_product_attribute_lookup_update_callback',
'args' => $args,
'status' => \ActionScheduler_Store::STATUS_PENDING,
),
'ids'
);
if ( empty( $already_scheduled ) ) {
$queue->schedule_single(
WC()->call_function( 'time' ) + 1,
'woocommerce_run_product_attribute_lookup_update_callback',
$args,
'woocommerce-db-updates'
);
}
}
/**
* Perform an update of the lookup table for a specific product.
*
* @param int $product_id The product id to perform the update for.
* @param int $action The action to perform, one of the ACTION_ constants.
*/
private function run_update_callback( int $product_id, int $action ) {
if ( ! $this->check_lookup_table_exists() ) {
return;
}
$product = WC()->call_function( 'wc_get_product', $product_id );
if ( ! $product ) {
$action = self::ACTION_DELETE;
}
switch ( $action ) {
case self::ACTION_INSERT:
$this->delete_data_for( $product_id );
$this->create_data_for( $product );
break;
case self::ACTION_UPDATE_STOCK:
$this->update_stock_status_for( $product );
break;
case self::ACTION_DELETE:
$this->delete_data_for( $product_id );
break;
}
}
/**
* Determine the type of action to perform depending on the received changeset.
*
* @param array|null $changeset The changeset received by on_product_changed.
* @return int One of the ACTION_ constants.
*/
private function get_update_action( $changeset ) {
if ( is_null( $changeset ) ) {
// No changeset at all means that the product is new.
return self::ACTION_INSERT;
}
$keys = array_keys( $changeset );
// Order matters:
// - The change with the most precedence is a change in catalog visibility
// (which will result in all data being regenerated or deleted).
// - Then a change in attributes (all data will be regenerated).
// - And finally a change in stock status (existing data will be updated).
// Thus these conditions must be checked in that same order.
if ( in_array( 'catalog_visibility', $keys, true ) ) {
$new_visibility = $changeset['catalog_visibility'];
if ( 'visible' === $new_visibility || 'catalog' === $new_visibility ) {
return self::ACTION_INSERT;
} else {
return self::ACTION_DELETE;
}
}
if ( in_array( 'attributes', $keys, true ) ) {
return self::ACTION_INSERT;
}
if ( array_intersect( $keys, array( 'stock_quantity', 'stock_status', 'manage_stock' ) ) ) {
return self::ACTION_UPDATE_STOCK;
}
return self::ACTION_NONE;
}
/**
* Update the stock status of the lookup table entries for a given product.
*
* @param \WC_Product $product The product to update the entries for.
*/
private function update_stock_status_for( \WC_Product $product ) {
global $wpdb;
$in_stock = $product->is_in_stock();
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
$wpdb->prepare(
'UPDATE ' . $this->lookup_table_name . ' SET in_stock = %d WHERE product_id = %d',
$in_stock ? 1 : 0,
$product->get_id()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Delete the lookup table contents related to a given product or variation,
* if it's a variable product it deletes the information for variations too.
* This must be invoked after a product or a variation is trashed or deleted.
*
* @param int|\WC_Product $product Product object or product id.
*/
public function on_product_deleted( $product ) {
if ( ! $this->check_lookup_table_exists() ) {
return;
}
if ( is_a( $product, \WC_Product::class ) ) {
$product_id = $product->get_id();
} else {
$product_id = $product;
}
$this->maybe_schedule_update( $product_id, self::ACTION_DELETE );
}
/**
* Create the lookup data for a given product, if a variable product is passed
* the information is created for all of its variations.
* This method is intended to be called from the data regenerator.
*
* @param int|WC_Product $product Product object or id.
* @throws \Exception A variation object is passed.
*/
public function create_data_for_product( $product ) {
if ( ! is_a( $product, \WC_Product::class ) ) {
$product = WC()->call_function( 'wc_get_product', $product );
}
if ( $this->is_variation( $product ) ) {
throw new \Exception( "LookupDataStore::create_data_for_product can't be called for variations." );
}
$this->delete_data_for( $product->get_id() );
$this->create_data_for( $product );
}
/**
* Create lookup table data for a given product.
*
* @param \WC_Product $product The product to create the data for.
*/
private function create_data_for( \WC_Product $product ) {
if ( $this->is_variation( $product ) ) {
$this->create_data_for_variation( $product );
} elseif ( $this->is_variable_product( $product ) ) {
$this->create_data_for_variable_product( $product );
} else {
$this->create_data_for_simple_product( $product );
}
}
/**
* Delete all the lookup table entries for a given product,
* if it's a variable product information for variations is deleted too.
* *
* @param int $product_id Simple product id, or main/parent product id for variable products. * @param int $product_id Simple product id, or main/parent product id for variable products.
*/ */
private function delete_lookup_table_entries_for( int $product_id ) { private function delete_data_for( int $product_id ) {
global $wpdb; global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( $wpdb->query(
$wpdb->prepare( $wpdb->prepare(
'DELETE FROM ' . $this->lookup_table_name . ' WHERE product_or_parent_id = %d', 'DELETE FROM ' . $this->lookup_table_name . ' WHERE product_id = %d OR product_or_parent_id = %d',
$product_id,
$product_id $product_id
) )
); );
@ -122,7 +410,7 @@ class LookupDataStore {
* *
* @param \WC_Product $product The product to create the entries for. * @param \WC_Product $product The product to create the entries for.
*/ */
private function create_lookup_table_entries_for_simple_product( \WC_Product $product ) { private function create_data_for_simple_product( \WC_Product $product ) {
$product_attributes_data = $this->get_attribute_taxonomies( $product ); $product_attributes_data = $this->get_attribute_taxonomies( $product );
$has_stock = $product->is_in_stock(); $has_stock = $product->is_in_stock();
$product_id = $product->get_id(); $product_id = $product->get_id();
@ -140,7 +428,7 @@ class LookupDataStore {
* *
* @param \WC_Product_Variable $product The product to create the entries for. * @param \WC_Product_Variable $product The product to create the entries for.
*/ */
private function create_lookup_table_entries_for_variable_product( \WC_Product_Variable $product ) { private function create_data_for_variable_product( \WC_Product_Variable $product ) {
$product_attributes_data = $this->get_attribute_taxonomies( $product ); $product_attributes_data = $this->get_attribute_taxonomies( $product );
$variation_attributes_data = array_filter( $variation_attributes_data = array_filter(
$product_attributes_data, $product_attributes_data,
@ -170,17 +458,53 @@ class LookupDataStore {
foreach ( $variation_attributes_data as $taxonomy => $data ) { foreach ( $variation_attributes_data as $taxonomy => $data ) {
foreach ( $variations as $variation ) { foreach ( $variations as $variation ) {
$variation_id = $variation->get_id(); $this->insert_lookup_table_data_for_variation( $variation, $taxonomy, $main_product_id, $data['term_ids'], $term_ids_by_slug_cache );
$variation_has_stock = $variation->is_in_stock(); }
$variation_definition_term_id = $this->get_variation_definition_term_id( $variation, $taxonomy, $term_ids_by_slug_cache ); }
if ( $variation_definition_term_id ) { }
$this->insert_lookup_table_data( $variation_id, $main_product_id, $taxonomy, $variation_definition_term_id, true, $variation_has_stock );
} else { /**
$term_ids_for_taxonomy = $data['term_ids']; * Create all the necessary lookup data for a given variation.
foreach ( $term_ids_for_taxonomy as $term_id ) { *
$this->insert_lookup_table_data( $variation_id, $main_product_id, $taxonomy, $term_id, true, $variation_has_stock ); * @param \WC_Product_Variation $variation The variation to create entries for.
} */
} private function create_data_for_variation( \WC_Product_Variation $variation ) {
$main_product = WC()->call_function( 'wc_get_product', $variation->get_parent_id() );
$product_attributes_data = $this->get_attribute_taxonomies( $main_product );
$variation_attributes_data = array_filter(
$product_attributes_data,
function( $item ) {
return $item['used_for_variations'];
}
);
$term_ids_by_slug_cache = $this->get_term_ids_by_slug_cache( array_keys( $variation_attributes_data ) );
foreach ( $variation_attributes_data as $taxonomy => $data ) {
$this->insert_lookup_table_data_for_variation( $variation, $taxonomy, $main_product->get_id(), $data['term_ids'], $term_ids_by_slug_cache );
}
}
/**
* Create lookup table entries for a given variation, corresponding to a given taxonomy and a set of term ids.
*
* @param \WC_Product_Variation $variation The variation to create entries for.
* @param string $taxonomy The taxonomy to create the entries for.
* @param int $main_product_id The parent product id.
* @param array $term_ids The term ids to create entries for.
* @param array $term_ids_by_slug_cache A dictionary of term ids by term slug, as returned by 'get_term_ids_by_slug_cache'.
*/
private function insert_lookup_table_data_for_variation( \WC_Product_Variation $variation, string $taxonomy, int $main_product_id, array $term_ids, array $term_ids_by_slug_cache ) {
$variation_id = $variation->get_id();
$variation_has_stock = $variation->is_in_stock();
$variation_definition_term_id = $this->get_variation_definition_term_id( $variation, $taxonomy, $term_ids_by_slug_cache );
if ( $variation_definition_term_id ) {
$this->insert_lookup_table_data( $variation_id, $main_product_id, $taxonomy, $variation_definition_term_id, true, $variation_has_stock );
} else {
$term_ids_for_taxonomy = $term_ids;
foreach ( $term_ids_for_taxonomy as $term_id ) {
$this->insert_lookup_table_data( $variation_id, $main_product_id, $taxonomy, $term_id, true, $variation_has_stock );
} }
} }
} }
@ -338,4 +662,27 @@ class LookupDataStore {
); );
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
} }
/**
* Tells if a lookup table regeneration is currently in progress.
*
* @return bool True if a lookup table regeneration is already in progress.
*/
public function regeneration_is_in_progress() {
return 'yes' === get_option( 'woocommerce_attribute_lookup__regeneration_in_progress', null );
}
/**
* Set a permanent flag (via option) indicating that the lookup table regeneration is in process.
*/
public function set_regeneration_in_progress_flag() {
update_option( 'woocommerce_attribute_lookup__regeneration_in_progress', 'yes' );
}
/**
* Remove the flag indicating that the lookup table regeneration is in process.
*/
public function unset_regeneration_in_progress_flag() {
delete_option( 'woocommerce_attribute_lookup__regeneration_in_progress' );
}
} }

View File

@ -31,7 +31,7 @@ class FakeQueue implements \WC_Queue_Interface {
* *
* @var array * @var array
*/ */
public $methods_called = array(); private $methods_called = array();
// phpcs:disable Squiz.Commenting.FunctionComment.Missing // phpcs:disable Squiz.Commenting.FunctionComment.Missing
@ -72,7 +72,13 @@ class FakeQueue implements \WC_Queue_Interface {
} }
public function search( $args = array(), $return_format = OBJECT ) { public function search( $args = array(), $return_format = OBJECT ) {
// TODO: Implement search() method. $result = array();
foreach ( $this->methods_called as $method_called ) {
if ( $method_called['args'] === $args['args'] && $method_called['hook'] === $args['hook'] ) {
$result[] = $method_called;
}
}
return $result;
} }
// phpcs:enable Squiz.Commenting.FunctionComment.Missing // phpcs:enable Squiz.Commenting.FunctionComment.Missing
@ -94,4 +100,21 @@ class FakeQueue implements \WC_Queue_Interface {
$this->methods_called[] = array_merge( $value, $extra_args ); $this->methods_called[] = array_merge( $value, $extra_args );
} }
/**
* Get the data about the methods called so far.
*
* @return array The current value of $methods_called.
*/
public function get_methods_called() {
return $this->methods_called;
}
/**
* Clears the collection of the methods called so far.
*/
public function clear_methods_called() {
$this->methods_called = array();
}
} }

View File

@ -51,14 +51,14 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
$this->lookup_data_store = new class() extends LookupDataStore { $this->lookup_data_store = new class() extends LookupDataStore {
public $passed_products = array(); public $passed_products = array();
public function update_data_for_product( $product ) { public function create_data_for_product( $product ) {
$this->passed_products[] = $product; $this->passed_products[] = $product;
} }
}; };
// phpcs:enable Squiz.Commenting // phpcs:enable Squiz.Commenting
// This is needed to prevent the hook to act on the already registered LookupDataStore class. // This is needed to prevent the hook to act on the already registered LookupDataStore class.
remove_all_actions( 'woocommerce_run_product_attribute_lookup_update_callback' ); remove_all_actions( 'woocommerce_run_product_attribute_lookup_regeneration_callback' );
$container = wc_get_container(); $container = wc_get_container();
$container->reset_all_resolved(); $container->reset_all_resolved();
@ -128,10 +128,10 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
'method' => 'schedule_single', 'method' => 'schedule_single',
'args' => array(), 'args' => array(),
'timestamp' => 1001, 'timestamp' => 1001,
'hook' => 'woocommerce_run_product_attribute_lookup_update_callback', 'hook' => 'woocommerce_run_product_attribute_lookup_regeneration_callback',
'group' => 'woocommerce-db-updates', 'group' => 'woocommerce-db-updates',
); );
$actual_enqueued = current( $this->queue->methods_called ); $actual_enqueued = current( $this->queue->get_methods_called() );
$this->assertEquals( sort( $expected_enqueued ), sort( $actual_enqueued ) ); $this->assertEquals( sort( $expected_enqueued ), sort( $actual_enqueued ) );
} }
@ -158,7 +158,7 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
$this->assertFalse( get_option( 'woocommerce_attribute_lookup__last_product_id_to_process' ) ); $this->assertFalse( get_option( 'woocommerce_attribute_lookup__last_product_id_to_process' ) );
$this->assertFalse( get_option( 'woocommerce_attribute_lookup__last_products_page_processed' ) ); $this->assertFalse( get_option( 'woocommerce_attribute_lookup__last_products_page_processed' ) );
$this->assertEquals( 'no', get_option( 'woocommerce_attribute_lookup__enabled' ) ); $this->assertEquals( 'no', get_option( 'woocommerce_attribute_lookup__enabled' ) );
$this->assertEmpty( $this->queue->methods_called ); $this->assertEmpty( $this->queue->get_methods_called() );
} }
/** /**
@ -184,11 +184,11 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
); );
$this->sut->initiate_regeneration(); $this->sut->initiate_regeneration();
$this->queue->methods_called = array(); $this->queue->clear_methods_called();
update_option( 'woocommerce_attribute_lookup__last_products_page_processed', 7 ); update_option( 'woocommerce_attribute_lookup__last_products_page_processed', 7 );
do_action( 'woocommerce_run_product_attribute_lookup_update_callback' ); do_action( 'woocommerce_run_product_attribute_lookup_regeneration_callback' );
$this->assertEquals( array( 1, 2, 3 ), $this->lookup_data_store->passed_products ); $this->assertEquals( array( 1, 2, 3 ), $this->lookup_data_store->passed_products );
$this->assertEquals( array( 8 ), $requested_products_pages ); $this->assertEquals( array( 8 ), $requested_products_pages );
@ -198,10 +198,10 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
'method' => 'schedule_single', 'method' => 'schedule_single',
'args' => array(), 'args' => array(),
'timestamp' => 1001, 'timestamp' => 1001,
'hook' => 'woocommerce_run_product_attribute_lookup_update_callback', 'hook' => 'woocommerce_run_product_attribute_lookup_regeneration_callback',
'group' => 'woocommerce-db-updates', 'group' => 'woocommerce-db-updates',
); );
$actual_enqueued = current( $this->queue->methods_called ); $actual_enqueued = current( $this->queue->get_methods_called() );
$this->assertEquals( sort( $expected_enqueued ), sort( $actual_enqueued ) ); $this->assertEquals( sort( $expected_enqueued ), sort( $actual_enqueued ) );
} }
@ -231,14 +231,14 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
); );
$this->sut->initiate_regeneration(); $this->sut->initiate_regeneration();
$this->queue->methods_called = array(); $this->queue->clear_methods_called();
do_action( 'woocommerce_run_product_attribute_lookup_update_callback' ); do_action( 'woocommerce_run_product_attribute_lookup_regeneration_callback' );
$this->assertEquals( $product_ids, $this->lookup_data_store->passed_products ); $this->assertEquals( $product_ids, $this->lookup_data_store->passed_products );
$this->assertFalse( get_option( 'woocommerce_attribute_lookup__last_product_id_to_process' ) ); $this->assertFalse( get_option( 'woocommerce_attribute_lookup__last_product_id_to_process' ) );
$this->assertFalse( get_option( 'woocommerce_attribute_lookup__last_products_page_processed' ) ); $this->assertFalse( get_option( 'woocommerce_attribute_lookup__last_products_page_processed' ) );
$this->assertEquals( 'no', get_option( 'woocommerce_attribute_lookup__enabled' ) ); $this->assertEquals( 'no', get_option( 'woocommerce_attribute_lookup__enabled' ) );
$this->assertEmpty( $this->queue->methods_called ); $this->assertEmpty( $this->queue->get_methods_called() );
} }
} }

View File

@ -74,7 +74,7 @@ class FiltererTest extends \WC_Unit_Test_Case {
$child->delete( true ); $child->delete( true );
} else { } else {
$child->set_parent_id( 0 ); $child->set_parent_id( 0 );
$child->save(); $this->save( $child );
} }
} }
@ -86,6 +86,27 @@ class FiltererTest extends \WC_Unit_Test_Case {
\WC_Query::reset_chosen_attributes(); \WC_Query::reset_chosen_attributes();
} }
/**
* Save a product and delete any lookup table data that may have been automatically inserted
* (for the purposes of unit testing we want to insert this data manually)
*
* @param \WC_Product $product The product to save and delete lookup table data for.
*/
private function save( \WC_Product $product ) {
global $wpdb;
$product->save();
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->prefix}wc_product_attributes_lookup WHERE product_id = %d",
$product->get_id()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}
/** /**
* Core function to create a product. * Core function to create a product.
* *
@ -165,7 +186,7 @@ class FiltererTest extends \WC_Unit_Test_Case {
$product->set_stock_status( $in_stock ? 'instock' : 'outofstock' ); $product->set_stock_status( $in_stock ? 'instock' : 'outofstock' );
$product->save(); $this->save( $product );
if ( empty( $attribute_terms_by_name ) ) { if ( empty( $attribute_terms_by_name ) ) {
return $product; return $product;
@ -234,7 +255,7 @@ class FiltererTest extends \WC_Unit_Test_Case {
) )
); );
$product->save(); $this->save( $product );
$product_id = $product->get_id(); $product_id = $product->get_id();
@ -259,7 +280,7 @@ class FiltererTest extends \WC_Unit_Test_Case {
} }
$variation->set_attributes( $attributes ); $variation->set_attributes( $attributes );
$variation->set_stock_status( $variation_data['in_stock'] ? 'instock' : 'outofstock' ); $variation->set_stock_status( $variation_data['in_stock'] ? 'instock' : 'outofstock' );
$variation->save(); $this->save( $variation );
$variation_ids[] = $variation->get_id(); $variation_ids[] = $variation->get_id();
} }

View File

@ -9,6 +9,7 @@ use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore; use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
use Automattic\WooCommerce\Testing\Tools\FakeQueue; use Automattic\WooCommerce\Testing\Tools\FakeQueue;
use Automattic\WooCommerce\Utilities\ArrayUtil; use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\FunctionsMockerHack;
/** /**
* Tests for the LookupDataStore class. * Tests for the LookupDataStore class.
@ -23,45 +24,62 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
*/ */
private $sut; private $sut;
/**
* The lookup table name.
*
* @var string
*/
private $lookup_table_name;
/**
* Runs after all the tests in the class.
*/
public static function tearDownAfterClass() {
parent::tearDownAfterClass();
wc_get_container()->get( DataRegenerator::class )->delete_all_attributes_lookup_data();
}
/** /**
* Runs before each test. * Runs before each test.
*/ */
public function setUp() { public function setUp() {
global $wpdb; global $wpdb;
$this->sut = new LookupDataStore(); $this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup';
$this->sut = new LookupDataStore();
$this->reset_legacy_proxy_mocks();
$this->register_legacy_proxy_class_mocks(
array(
\WC_Queue::class => new FakeQueue(),
)
);
// Initiating regeneration with a fake queue will just create the lookup table in the database. // Initiating regeneration with a fake queue will just create the lookup table in the database.
add_filter(
'woocommerce_queue_class',
function() {
return FakeQueue::class;
}
);
$this->get_instance_of( DataRegenerator::class )->initiate_regeneration(); $this->get_instance_of( DataRegenerator::class )->initiate_regeneration();
} }
/** /**
* @testdox `test_update_data_for_product` throws an exception if a variation is passed. * @testdox `create_data_for_product` throws an exception if a variation is passed.
*/ */
public function test_update_data_for_product_throws_if_variation_is_passed() { public function test_create_data_for_product_throws_if_variation_is_passed() {
$product = new \WC_Product_Variation(); $product = new \WC_Product_Variation();
$this->expectException( \Exception::class ); $this->expectException( \Exception::class );
$this->expectExceptionMessage( "LookupDataStore::update_data_for_product can't be called for variations." ); $this->expectExceptionMessage( "LookupDataStore::create_data_for_product can't be called for variations." );
$this->sut->update_data_for_product( $product ); $this->sut->create_data_for_product( $product );
} }
/** /**
* @testdox `test_update_data_for_product` creates the appropriate entries for simple products, skipping custom product attributes. * @testdox `create_data_for_product` creates the appropriate entries for simple products, skipping custom product attributes.
* *
* @testWith [true] * @testWith [true]
* [false] * [false]
* *
* @param bool $in_stock 'true' if the product is supposed to be in stock. * @param bool $in_stock 'true' if the product is supposed to be in stock.
*/ */
public function test_update_data_for_simple_product( $in_stock ) { public function test_create_data_for_simple_product( $in_stock ) {
$product = new \WC_Product_Simple(); $product = new \WC_Product_Simple();
$product->set_id( 10 ); $product->set_id( 10 );
$this->set_product_attributes( $this->set_product_attributes(
@ -90,7 +108,7 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
$expected_in_stock = 0; $expected_in_stock = 0;
} }
$this->sut->update_data_for_product( $product ); $this->sut->create_data_for_product( $product );
$expected = array( $expected = array(
array( array(
@ -133,7 +151,7 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
} }
/** /**
* @testdox `test_update_data_for_product` creates the appropriate entries for variable products. * @testdox `create_data_for_product` creates the appropriate entries for variable products.
*/ */
public function test_update_data_for_variable_product() { public function test_update_data_for_variable_product() {
$products = array(); $products = array();
@ -239,7 +257,7 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
$products[1001] = $variation_1; $products[1001] = $variation_1;
$products[1002] = $variation_2; $products[1002] = $variation_2;
$this->sut->update_data_for_product( $product ); $this->sut->create_data_for_product( $product );
$expected = array( $expected = array(
// Main product: one entry for each of the regular attribute values, // Main product: one entry for each of the regular attribute values,
@ -331,6 +349,748 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
$this->assertEquals( sort( $expected ), sort( $actual ) ); $this->assertEquals( sort( $expected ), sort( $actual ) );
} }
/**
* @testdox Deleting a simple product schedules deletion of lookup table entries when the "direct updates" option is off.
*
* @testWith ["wp_trash_post"]
* ["delete_post"]
* ["delete_method_in_product"]
* ["force_delete_method_in_product"]
*
* @param string $deletion_mechanism The mechanism used for deletion, one of: 'wp_trash_post', 'delete_post', 'delete_method_in_product', 'force_delete_method_in_product'.
*/
public function test_deleting_simple_product_schedules_deletion( $deletion_mechanism ) {
$this->set_direct_update_option( false );
$product = new \WC_Product_Simple();
$product_id = 10;
$product->set_id( $product_id );
$this->save( $product );
$this->register_legacy_proxy_function_mocks(
array(
'get_post_type' => function( $id ) use ( $product ) {
if ( $id === $product->get_id() || $id === $product ) {
return 'product';
} else {
return get_post_type( $id );
}
},
'time' => function() {
return 100;
},
'current_user_can' => function( $capability, ...$args ) {
if ( 'delete_posts' === $capability ) {
return true;
} else {
return current_user_can( $capability, $args );
}
},
)
);
$this->delete_product( $product, $deletion_mechanism );
$queue_calls = WC()->get_instance_of( \WC_Queue::class )->get_methods_called();
$this->assertEquals( 1, count( $queue_calls ) );
$expected = array(
'method' => 'schedule_single',
'args' =>
array(
$product_id,
LookupDataStore::ACTION_DELETE,
),
'group' => 'woocommerce-db-updates',
'timestamp' => 101,
'hook' => 'woocommerce_run_product_attribute_lookup_update_callback',
);
$this->assertEquals( $expected, $queue_calls[0] );
}
/**
* Delete a product or variation.
*
* @param \WC_Product $product The product to delete.
* @param string $deletion_mechanism The mechanism used for deletion, one of: 'wp_trash_post', 'delete_post', 'delete_method_in_product', 'force_delete_method_in_product'.
*/
private function delete_product( \WC_Product $product, string $deletion_mechanism ) {
// We can't use the 'wp_trash_post' and 'delete_post' functions directly
// because these invoke 'get_post', which fails because tests runs within an
// uncommitted database transaction. Being WP core functions they can't be mocked or hacked.
// So instead, we trigger the actions that the tested functionality captures.
switch ( $deletion_mechanism ) {
case 'wp_trash_post':
do_action( 'wp_trash_post', $product );
break;
case 'delete_post':
do_action( 'delete_post', $product->get_id() );
break;
case 'delete_method_in_product':
$product->delete( false );
break;
case 'force_delete_method_in_product':
$product->delete( true );
break;
}
}
/**
* @testdox Deleting a variable product schedules deletion of lookup table entries when the "direct updates" option is off.
*
* @testWith ["wp_trash_post"]
* ["delete_post"]
* ["delete_method_in_product"]
* ["force_delete_method_in_product"]
*
* @param string $deletion_mechanism The mechanism used for deletion, one of: 'wp_trash_post', 'delete_post', 'delete_method_in_product', 'force_delete_method_in_product'.
*/
public function test_deleting_variable_product_schedules_deletion( $deletion_mechanism ) {
$this->set_direct_update_option( false );
$product = new \WC_Product_Variable();
$product->set_id( 1000 );
$variation = new \WC_Product_Variation();
$variation->set_id( 1001 );
$product->set_children( array( 1001 ) );
$this->save( $product );
$product_id = $product->get_id();
$this->register_legacy_proxy_function_mocks(
array(
'get_post_type' => function( $id ) use ( $product, $variation ) {
if ( $id === $product->get_id() || $id === $product ) {
return 'product';
} elseif ( $id === $variation->get_id() || $id === $variation ) {
return 'product_variation';
} else {
return get_post_type( $id );
}
},
'time' => function() {
return 100;
},
'current_user_can' => function( $capability, ...$args ) {
if ( 'delete_posts' === $capability ) {
return true;
} else {
return current_user_can( $capability, $args );
}
},
)
);
$this->delete_product( $product, $deletion_mechanism );
$queue_calls = WC()->get_instance_of( \WC_Queue::class )->get_methods_called();
$this->assertEquals( 1, count( $queue_calls ) );
$expected = array(
'method' => 'schedule_single',
'args' =>
array(
$product_id,
LookupDataStore::ACTION_DELETE,
),
'group' => 'woocommerce-db-updates',
'timestamp' => 101,
'hook' => 'woocommerce_run_product_attribute_lookup_update_callback',
);
$this->assertEquals( $expected, $queue_calls[0] );
}
/**
* @testdox Deleting a variation schedules deletion of lookup table entries when the "direct updates" option is off.
*
* @testWith ["wp_trash_post"]
* ["delete_post"]
* ["delete_method_in_product"]
* ["force_delete_method_in_product"]
*
* @param string $deletion_mechanism The mechanism used for deletion, one of: 'wp_trash_post', 'delete_post', 'delete_method_in_product', 'force_delete_method_in_product'.
*/
public function test_deleting_variation_schedules_deletion( $deletion_mechanism ) {
$this->set_direct_update_option( false );
$product = new \WC_Product_Variable();
$product->set_id( 1000 );
$variation = new \WC_Product_Variation();
$variation->set_id( 1001 );
$product->set_children( array( 1001 ) );
$this->save( $product );
$variation_id = $product->get_id();
$this->register_legacy_proxy_function_mocks(
array(
'get_post_type' => function( $id ) use ( $product, $variation ) {
if ( $id === $product->get_id() || $id === $product ) {
return 'product';
} elseif ( $id === $variation->get_id() || $id === $variation ) {
return 'product_variation';
} else {
return get_post_type( $id );
}
},
'time' => function() {
return 100;
},
'current_user_can' => function( $capability, ...$args ) {
if ( 'delete_posts' === $capability ) {
return true;
} else {
return current_user_can( $capability, $args );
}
},
)
);
$this->delete_product( $product, $deletion_mechanism );
$queue_calls = WC()->get_instance_of( \WC_Queue::class )->get_methods_called();
$this->assertEquals( 1, count( $queue_calls ) );
$expected = array(
'method' => 'schedule_single',
'args' =>
array(
$variation_id,
LookupDataStore::ACTION_DELETE,
),
'group' => 'woocommerce-db-updates',
'timestamp' => 101,
'hook' => 'woocommerce_run_product_attribute_lookup_update_callback',
);
$this->assertEquals( $expected, $queue_calls[0] );
}
/**
* @testdox 'on_product_deleted' doesn't schedule duplicate deletions (for the same product).
*/
public function test_no_duplicate_deletions_are_scheduled() {
$this->set_direct_update_option( false );
$this->register_legacy_proxy_function_mocks(
array(
'time' => function() {
return 100;
},
)
);
$this->sut->on_product_deleted( 1 );
$this->sut->on_product_deleted( 1 );
$this->sut->on_product_deleted( 2 );
$queue_calls = WC()->get_instance_of( \WC_Queue::class )->get_methods_called();
$this->assertEquals( 2, count( $queue_calls ) );
$expected = array(
array(
'method' => 'schedule_single',
'args' =>
array(
1,
LookupDataStore::ACTION_DELETE,
),
'group' => 'woocommerce-db-updates',
'timestamp' => 101,
'hook' => 'woocommerce_run_product_attribute_lookup_update_callback',
),
array(
'method' => 'schedule_single',
'args' =>
array(
2,
LookupDataStore::ACTION_DELETE,
),
'group' => 'woocommerce-db-updates',
'timestamp' => 101,
'hook' => 'woocommerce_run_product_attribute_lookup_update_callback',
),
);
$this->assertEquals( $expected, $queue_calls );
}
/**
* @testdox 'on_product_deleted' deletes the data for a variation when the "direct updates" option is on.
*/
public function test_direct_deletion_of_variation() {
global $wpdb;
$this->set_direct_update_option( true );
$variation = new \WC_Product_Variation();
$variation->set_id( 2 );
$this->save( $variation );
$this->insert_lookup_table_data( 1, 1, 'pa_foo', 10, false, true );
$this->insert_lookup_table_data( 2, 1, 'pa_bar', 20, true, true );
$this->sut->on_product_deleted( $variation );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$rows = $wpdb->get_results( 'SELECT DISTINCT product_id FROM ' . $this->lookup_table_name, ARRAY_N );
$this->assertEquals( array( 1 ), $rows[0] );
}
/**
* @testdox 'on_product_deleted' deletes the data for a product and its variations when the "direct updates" option is on.
*/
public function test_direct_deletion_of_product() {
global $wpdb;
$this->set_direct_update_option( true );
$product = new \WC_Product();
$product->set_id( 1 );
$this->save( $product );
$this->insert_lookup_table_data( 1, 1, 'pa_foo', 10, false, true );
$this->insert_lookup_table_data( 2, 1, 'pa_bar', 20, true, true );
$this->insert_lookup_table_data( 3, 3, 'pa_foo', 10, false, true );
$this->sut->on_product_deleted( $product );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$rows = $wpdb->get_results( 'SELECT DISTINCT product_id FROM ' . $this->lookup_table_name, ARRAY_N );
$this->assertEquals( array( 3 ), $rows[0] );
}
/**
* @testdox Changing the stock status of a simple product schedules update of lookup table entries when the "direct updates" option is off.
*
* @testWith ["instock", "outofstock"]
* ["outofstock", "instock"]
*
* @param string $old_status Original status of the product.
* @param string $new_status New status of the product.
*/
public function test_changing_simple_product_stock_schedules_update( string $old_status, string $new_status ) {
$this->set_direct_update_option( false );
$product = new \WC_Product_Simple();
$product_id = 10;
$product->set_id( $product_id );
$product->set_stock_status( $old_status );
$this->save( $product );
$this->register_legacy_proxy_function_mocks(
array(
'time' => function() {
return 100;
},
)
);
$product->set_stock_status( $new_status );
$product->save();
$queue_calls = WC()->get_instance_of( \WC_Queue::class )->get_methods_called();
$this->assertEquals( 1, count( $queue_calls ) );
$expected = array(
'method' => 'schedule_single',
'args' =>
array(
$product_id,
LookupDataStore::ACTION_UPDATE_STOCK,
),
'group' => 'woocommerce-db-updates',
'timestamp' => 101,
'hook' => 'woocommerce_run_product_attribute_lookup_update_callback',
);
$this->assertEquals( $expected, $queue_calls[0] );
}
/**
* @testdox Changing the stock status of a variable product or a variation schedules update of lookup table entries when the "direct updates" option is off.
*
* @testWith ["instock", "outofstock", true]
* ["outofstock", "instock", true]
* ["instock", "outofstock", false]
* ["outofstock", "instock", false]
*
* @param string $old_status Original status of the product.
* @param string $new_status New status of the product.
* @param bool $change_variation_stock True if the stock of the variation changes.
*/
public function test_changing_variable_product_or_variation_stock_schedules_update( string $old_status, string $new_status, bool $change_variation_stock ) {
$this->set_direct_update_option( false );
$product = new \WC_Product_Variable();
$product_id = 1000;
$product->set_id( $product_id );
$variation = new \WC_Product_Variation();
$variation_id = 1001;
$variation->set_id( $variation_id );
$variation->set_stock_status( $old_status );
$variation->save();
$product->set_children( array( 1001 ) );
$product->set_stock_status( $old_status );
$this->save( $product );
$this->register_legacy_proxy_function_mocks(
array(
'time' => function () {
return 100;
},
)
);
if ( $change_variation_stock ) {
$variation->set_stock_status( $new_status );
$variation->save();
} else {
$product->set_stock_status( $new_status );
$product->save();
}
$queue_calls = WC()->get_instance_of( \WC_Queue::class )->get_methods_called();
$this->assertEquals( 1, count( $queue_calls ) );
$expected = array(
'method' => 'schedule_single',
'args' =>
array(
$change_variation_stock ? $variation_id : $product_id,
LookupDataStore::ACTION_UPDATE_STOCK,
),
'group' => 'woocommerce-db-updates',
'timestamp' => 101,
'hook' => 'woocommerce_run_product_attribute_lookup_update_callback',
);
$this->assertEquals( $expected, $queue_calls[0] );
}
/**
* Data provider for on_product_changed tests with direct update option set.
*
* @return array[]
*/
public function data_provider_for_test_on_product_changed_with_direct_updates() {
return array(
array(
null,
'creation',
),
array(
array( 'attributes' => array() ),
'creation',
),
array(
array( 'stock_quantity' => 1 ),
'update',
),
array(
array( 'stock_status' => 'instock' ),
'update',
),
array(
array( 'manage_stock' => true ),
'update',
),
array(
array( 'catalog_visibility' => 'visible' ),
'creation',
),
array(
array( 'catalog_visibility' => 'catalog' ),
'creation',
),
array(
array( 'catalog_visibility' => 'search' ),
'deletion',
),
array(
array( 'catalog_visibility' => 'hidden' ),
'deletion',
),
array(
array( 'foo' => 'bar' ),
'none',
),
);
}
/**
* @testdox 'on_product_changed' creates, updates deletes the data for a simple product depending on the changeset when the "direct updates" option is on.
*
* @dataProvider data_provider_for_test_on_product_changed_with_direct_updates
*
* @param array $changeset The changeset to test.
* @param string $expected_action The expected performed action, one of 'none', 'creation', 'update' or 'deletion'.
*/
public function test_on_product_changed_for_simple_product_with_direct_updates( $changeset, $expected_action ) {
global $wpdb;
$this->set_direct_update_option( true );
$product = new \WC_Product_Simple();
$product->set_id( 2 );
$product->set_stock_status( 'instock' );
$this->set_product_attributes(
$product,
array(
'pa_bar' => array(
'id' => 100,
'options' => array( 20 ),
),
)
);
$this->register_legacy_proxy_function_mocks(
array(
'wc_get_product' => function( $id ) use ( $product ) {
if ( $id === $product->get_id() || $id === $product ) {
return $product;
} else {
return wc_get_product( $id );
}
},
)
);
$this->insert_lookup_table_data( 1, 1, 'pa_foo', 10, false, true );
if ( 'creation' !== $expected_action ) {
$this->insert_lookup_table_data( 2, 2, 'pa_bar', 20, false, false );
}
$this->sut->on_product_changed( $product, $changeset );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$rows = $wpdb->get_results( 'SELECT * FROM ' . $this->lookup_table_name, ARRAY_N );
$expected = array( array( '1', '1', 'pa_foo', '10', '0', '1' ) );
// Differences:
// Creation or update: the product is stored as having stock.
// None: the product remains stored as not having stock.
if ( 'creation' === $expected_action || 'update' === $expected_action ) {
$expected[] = array( '2', '2', 'pa_bar', '20', '0', '1' );
} elseif ( 'none' === $expected_action ) {
$expected[] = array( '2', '2', 'pa_bar', '20', '0', '0' );
}
$this->assertEquals( $expected, $rows );
}
/**
* @testdox 'on_product_changed' creates, updates deletes the data for a variable product and if needed its variations depending on the changeset when the "direct updates" option is on.
*
* @dataProvider data_provider_for_test_on_product_changed_with_direct_updates
*
* @param array $changeset The changeset to test.
* @param string $expected_action The expected performed action, one of 'none', 'creation', 'update' or 'deletion'.
*/
public function test_on_variable_product_changed_for_variable_product_with_direct_updates( $changeset, $expected_action ) {
global $wpdb;
$this->set_direct_update_option( true );
$product = new \WC_Product_Variable();
$product->set_id( 2 );
$this->set_product_attributes(
$product,
array(
'non-variation-attribute' => array(
'id' => 100,
'options' => array( 10 ),
),
'variation-attribute' => array(
'id' => 200,
'options' => array( 20 ),
'variation' => true,
),
)
);
$product->set_stock_status( 'instock' );
$variation = new \WC_Product_Variation();
$variation->set_id( 3 );
$variation->set_attributes(
array(
'variation-attribute' => 'term_20',
)
);
$variation->set_stock_status( 'instock' );
$variation->set_parent_id( 2 );
$product->set_children( array( 3 ) );
$this->register_legacy_proxy_function_mocks(
array(
'get_terms' => function( $args ) {
switch ( $args['taxonomy'] ) {
case 'non-variation-attribute':
return array(
10 => 'term_10',
);
case 'variation-attribute':
return array(
20 => 'term_20',
);
default:
throw new \Exception( "Unexpected call to 'get_terms'" );
}
},
'wc_get_product' => function( $id ) use ( $product, $variation ) {
if ( $id === $product->get_id() || $id === $product ) {
return $product;
} elseif ( $id === $variation->get_id() || $id === $variation ) {
return $variation;
} else {
return wc_get_product( $id );
}
},
)
);
$this->insert_lookup_table_data( 1, 1, 'pa_foo', 10, false, true );
if ( 'creation' !== $expected_action ) {
$this->insert_lookup_table_data( 2, 2, 'non-variation-attribute', 10, false, false );
$this->insert_lookup_table_data( 3, 2, 'variation-attribute', 20, true, false );
}
$this->sut->on_product_changed( $product, $changeset );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$rows = $wpdb->get_results( 'SELECT * FROM ' . $this->lookup_table_name, ARRAY_N );
$expected = array( array( '1', '1', 'pa_foo', '10', '0', '1' ) );
// Differences:
// Creation: both main product and variation are stored as having stock.
// Update: main product only is updated as having stock (variation is supposed to get a separate update).
// None: both main product and variation are still stored as not having stock.
if ( 'creation' === $expected_action ) {
$expected[] = array( '2', '2', 'non-variation-attribute', '10', '0', '1' );
$expected[] = array( '3', '2', 'variation-attribute', '20', '1', '1' );
} elseif ( 'update' === $expected_action ) {
$expected[] = array( '2', '2', 'non-variation-attribute', '10', '0', '1' );
$expected[] = array( '3', '2', 'variation-attribute', '20', '1', '0' );
} elseif ( 'none' === $expected_action ) {
$expected[] = array( '2', '2', 'non-variation-attribute', '10', '0', '0' );
$expected[] = array( '3', '2', 'variation-attribute', '20', '1', '0' );
}
$this->assertEquals( $expected, $rows );
}
/**
* @testdox 'on_product_changed' creates, updates deletes the data for a variation depending on the changeset when the "direct updates" option is on.
*
* @dataProvider data_provider_for_test_on_product_changed_with_direct_updates
*
* @param array $changeset The changeset to test.
* @param string $expected_action The expected performed action, one of 'none', 'creation', 'update' or 'deletion'.
*/
public function test_on_variation_changed_for_variable_product_with_direct_updates( $changeset, $expected_action ) {
global $wpdb;
$this->set_direct_update_option( true );
$product = new \WC_Product_Variable();
$product->set_id( 2 );
$this->set_product_attributes(
$product,
array(
'non-variation-attribute' => array(
'id' => 100,
'options' => array( 10 ),
),
'variation-attribute' => array(
'id' => 200,
'options' => array( 20 ),
'variation' => true,
),
)
);
$product->set_stock_status( 'instock' );
$variation = new \WC_Product_Variation();
$variation->set_id( 3 );
$variation->set_attributes(
array(
'variation-attribute' => 'term_20',
)
);
$variation->set_stock_status( 'instock' );
$variation->set_parent_id( 2 );
$product->set_children( array( 3 ) );
$this->register_legacy_proxy_function_mocks(
array(
'get_terms' => function( $args ) {
switch ( $args['taxonomy'] ) {
case 'non-variation-attribute':
return array(
10 => 'term_10',
);
case 'variation-attribute':
return array(
20 => 'term_20',
);
default:
throw new \Exception( "Unexpected call to 'get_terms'" );
}
},
'wc_get_product' => function( $id ) use ( $product, $variation ) {
if ( $id === $product->get_id() || $id === $product ) {
return $product;
} elseif ( $id === $variation->get_id() || $id === $variation ) {
return $variation;
} else {
return wc_get_product( $id );
}
},
)
);
$this->insert_lookup_table_data( 1, 1, 'pa_foo', 10, false, true );
if ( 'creation' !== $expected_action ) {
$this->insert_lookup_table_data( 3, 2, 'variation-attribute', 20, true, false );
}
$this->sut->on_product_changed( $variation, $changeset );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$rows = $wpdb->get_results( 'SELECT * FROM ' . $this->lookup_table_name, ARRAY_N );
$expected = array( array( '1', '1', 'pa_foo', '10', '0', '1' ) );
// Differences:
// Creation or update: the variation is stored as having stock.
// None: the variation is still stored as not having stock.
if ( 'creation' === $expected_action || 'update' === $expected_action ) {
$expected[] = array( '3', '2', 'variation-attribute', '20', '1', '1' );
} elseif ( 'none' === $expected_action ) {
$expected[] = array( '3', '2', 'variation-attribute', '20', '1', '0' );
}
$this->assertEquals( $expected, $rows );
}
/** /**
* Set the product attributes from an array with this format: * Set the product attributes from an array with this format:
* *
@ -380,4 +1140,73 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
return $result; return $result;
} }
/**
* Set the value of the option for direct lookup table updates.
*
* @param bool $value True to set the option to 'yes', false for 'no'.
*/
private function set_direct_update_option( bool $value ) {
update_option( 'woocommerce_attribute_lookup__direct_updates', $value ? 'yes' : 'no' );
}
/**
* Save a product and delete any lookup table data that may have been automatically inserted
* (for the purposes of unit testing we want to insert this data manually)
*
* @param \WC_Product $product The product to save and delete lookup table data for.
*/
private function save( \WC_Product $product ) {
global $wpdb;
$product->save();
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->prefix}wc_product_attributes_lookup WHERE product_id = %d",
$product->get_id()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
$queue = WC()->get_instance_of( \WC_Queue::class );
$queue->clear_methods_called();
}
/**
* Insert one entry in the lookup table.
*
* @param int $product_id The product id.
* @param int $product_or_parent_id The product id for non-variable products, the main/parent product id for variations.
* @param string $taxonomy Taxonomy name.
* @param int $term_id Term id.
* @param bool $is_variation_attribute True if the taxonomy corresponds to an attribute used to define variations.
* @param bool $has_stock True if the product is in stock.
*/
private function insert_lookup_table_data( int $product_id, int $product_or_parent_id, string $taxonomy, int $term_id, bool $is_variation_attribute, bool $has_stock ) {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
$wpdb->prepare(
'INSERT INTO ' . $this->lookup_table_name . ' (
product_id,
product_or_parent_id,
taxonomy,
term_id,
is_variation_attribute,
in_stock)
VALUES
( %d, %d, %s, %d, %d, %d )',
$product_id,
$product_or_parent_id,
$taxonomy,
$term_id,
$is_variation_attribute ? 1 : 0,
$has_stock ? 1 : 0
)
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}
} }