diff --git a/composer.json b/composer.json
index 79d23dbc607..ee94ca040ca 100644
--- a/composer.json
+++ b/composer.json
@@ -22,7 +22,7 @@
"psr/container": "1.0.0",
"woocommerce/action-scheduler": "3.2.1",
"woocommerce/woocommerce-admin": "2.4.1",
- "woocommerce/woocommerce-blocks": "5.3.1"
+ "woocommerce/woocommerce-blocks": "5.5.1"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.4"
diff --git a/composer.lock b/composer.lock
index c25864dd87f..472d1eafe05 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "78971f2035da15d44d3941df88d5afbc",
+ "content-hash": "acab5cd3f2509342ed733e770638ab4c",
"packages": [
{
"name": "automattic/jetpack-autoloader",
@@ -584,16 +584,16 @@
},
{
"name": "woocommerce/woocommerce-blocks",
- "version": "v5.3.1",
+ "version": "v5.5.1",
"source": {
"type": "git",
"url": "https://github.com/woocommerce/woocommerce-gutenberg-products-block.git",
- "reference": "28c7c4f9b5cace9098fb2246ff93abe110a26bca"
+ "reference": "f3d8dbadb745cbb2544e86dfb3864e870146d197"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/woocommerce/woocommerce-gutenberg-products-block/zipball/28c7c4f9b5cace9098fb2246ff93abe110a26bca",
- "reference": "28c7c4f9b5cace9098fb2246ff93abe110a26bca",
+ "url": "https://api.github.com/repos/woocommerce/woocommerce-gutenberg-products-block/zipball/f3d8dbadb745cbb2544e86dfb3864e870146d197",
+ "reference": "f3d8dbadb745cbb2544e86dfb3864e870146d197",
"shasum": ""
},
"require": {
@@ -629,9 +629,9 @@
],
"support": {
"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": [
diff --git a/includes/abstracts/abstract-wc-product.php b/includes/abstracts/abstract-wc-product.php
index ccecf15e367..5f43b8780a0 100644
--- a/includes/abstracts/abstract-wc-product.php
+++ b/includes/abstracts/abstract-wc-product.php
@@ -9,6 +9,8 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
+use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore as ProductAttributesLookupDataStore;
+
/**
* Legacy product contains all deprecated methods for this class and can be
* 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 );
+ $state = $this->before_data_store_save_or_update();
+
if ( $this->get_id() ) {
+ $changeset = $this->get_changes();
$this->data_store->update( $this );
} else {
+ $changeset = null;
$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.
@@ -1393,6 +1404,25 @@ class WC_Product extends WC_Abstract_Legacy_Product {
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.
*
@@ -1400,10 +1430,12 @@ class WC_Product extends WC_Abstract_Legacy_Product {
* @return bool result
*/
public function delete( $force_delete = false ) {
- $deleted = parent::delete( $force_delete );
+ $product_id = $this->get_id();
+ $deleted = parent::delete( $force_delete );
if ( $deleted ) {
$this->maybe_defer_product_sync();
+ wc_get_container()->get( ProductAttributesLookupDataStore::class )->on_product_deleted( $product_id );
}
return $deleted;
diff --git a/includes/admin/views/html-admin-page-status-tools.php b/includes/admin/views/html-admin-page-status-tools.php
index b6d9dacaafc..31489a6ccdd 100644
--- a/includes/admin/views/html-admin-page-status-tools.php
+++ b/includes/admin/views/html-admin-page-status-tools.php
@@ -11,22 +11,44 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
+foreach ( $tools as $action_name => $tool ) {
+ ?>
+
+
-
+
+
diff --git a/includes/class-wc-post-data.php b/includes/class-wc-post-data.php
index 5ea84ff351f..8473de148c2 100644
--- a/includes/class-wc-post-data.php
+++ b/includes/class-wc-post-data.php
@@ -8,6 +8,9 @@
* @version 2.2.0
*/
+use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore as ProductAttributesLookupDataStore;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+
defined( 'ABSPATH' ) || exit;
/**
@@ -296,27 +299,31 @@ class WC_Post_Data {
* @param mixed $id ID of post being deleted.
*/
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;
}
- $post_type = get_post_type( $id );
-
+ $post_type = self::get_post_type( $id );
switch ( $post_type ) {
case 'product':
$data_store = WC_Data_Store::load( 'product-variable' );
$data_store->delete_variations( $id, true );
$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 ) {
wc_delete_product_transients( $parent_id );
}
+
break;
case 'product_variation':
$data_store = WC_Data_Store::load( 'product' );
$data_store->delete_from_lookup_table( $id, 'wc_product_meta_lookup' );
wc_delete_product_transients( wp_get_post_parent_id( $id ) );
+ $container->get( ProductAttributesLookupDataStore::class )->on_product_deleted( $id );
+
break;
case 'shop_order':
global $wpdb;
@@ -342,7 +349,7 @@ class WC_Post_Data {
return;
}
- $post_type = get_post_type( $id );
+ $post_type = self::get_post_type( $id );
// If this is an order, trash any refunds too.
if ( in_array( $post_type, wc_get_order_types( 'order-count' ), true ) ) {
@@ -360,6 +367,9 @@ class WC_Post_Data {
} elseif ( 'product' === $post_type ) {
$data_store = WC_Data_Store::load( 'product-variable' );
$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;
}
- $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 ) ) {
global $wpdb;
@@ -391,9 +401,23 @@ class WC_Post_Data {
$data_store->untrash_variations( $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.
*
diff --git a/includes/class-wc-product-variable.php b/includes/class-wc-product-variable.php
index 78900dd9fc1..a240c406a08 100644
--- a/includes/class-wc-product-variable.php
+++ b/includes/class-wc-product-variable.php
@@ -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() {
- $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 );
-
+ protected function before_data_store_save_or_update() {
// Get names before save.
$previous_name = $this->data['name'];
$new_name = $this->get_name( 'edit' );
- if ( $this->get_id() ) {
- $this->data_store->update( $this );
- } else {
- $this->data_store->create( $this );
- }
+ return array(
+ 'previous_name' => $previous_name,
+ 'new_name' => $new_name,
+ );
+ }
- $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 );
-
- /**
- * 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();
}
/*
diff --git a/includes/class-wc-product-variation.php b/includes/class-wc-product-variation.php
index ebd2838b46b..83b80fc206c 100644
--- a/includes/class-wc-product-variation.php
+++ b/includes/class-wc-product-variation.php
@@ -583,21 +583,4 @@ class WC_Product_Variation extends WC_Product_Simple {
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;
- }
}
diff --git a/includes/class-woocommerce.php b/includes/class-woocommerce.php
index 4aca7ed58a6..ec88905a0e3 100644
--- a/includes/class-woocommerce.php
+++ b/includes/class-woocommerce.php
@@ -8,10 +8,11 @@
defined( 'ABSPATH' ) || exit;
-use Automattic\WooCommerce\Internal\DownloadPermissionsAdjuster;
-use Automattic\WooCommerce\Internal\RestockRefundedItemsAdjuster;
use Automattic\WooCommerce\Internal\AssignDefaultCategory;
+use Automattic\WooCommerce\Internal\DownloadPermissionsAdjuster;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
+use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
+use Automattic\WooCommerce\Internal\RestockRefundedItemsAdjuster;
use Automattic\WooCommerce\Proxies\LegacyProxy;
/**
@@ -213,6 +214,7 @@ final class WooCommerce {
wc_get_container()->get( DownloadPermissionsAdjuster::class );
wc_get_container()->get( AssignDefaultCategory::class );
wc_get_container()->get( DataRegenerator::class );
+ wc_get_container()->get( LookupDataStore::class );
wc_get_container()->get( RestockRefundedItemsAdjuster::class );
}
diff --git a/includes/data-stores/class-wc-product-data-store-cpt.php b/includes/data-stores/class-wc-product-data-store-cpt.php
index 8a71ed6de96..190749d7db3 100644
--- a/includes/data-stores/class-wc-product-data-store-cpt.php
+++ b/includes/data-stores/class-wc-product-data-store-cpt.php
@@ -1118,7 +1118,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
$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;';
diff --git a/includes/data-stores/class-wc-webhook-data-store.php b/includes/data-stores/class-wc-webhook-data-store.php
index 7feed6e4cc4..e8b417e3a8f 100644
--- a/includes/data-stores/class-wc-webhook-data-store.php
+++ b/includes/data-stores/class-wc-webhook-data-store.php
@@ -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'] ) : '';
$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'] ) : '';
- $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 = '';
$exclude = '';
$date_created = '';
diff --git a/src/Internal/DependencyManagement/ServiceProviders/ProductAttributesLookupServiceProvider.php b/src/Internal/DependencyManagement/ServiceProviders/ProductAttributesLookupServiceProvider.php
index 31fba73fe63..173621078d6 100644
--- a/src/Internal/DependencyManagement/ServiceProviders/ProductAttributesLookupServiceProvider.php
+++ b/src/Internal/DependencyManagement/ServiceProviders/ProductAttributesLookupServiceProvider.php
@@ -9,7 +9,6 @@ use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\Filterer;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
-use Automattic\WooCommerce\Proxies\LegacyProxy;
/**
* Service provider for the ProductAttributesLookupServiceProvider namespace.
@@ -32,7 +31,7 @@ class ProductAttributesLookupServiceProvider extends AbstractServiceProvider {
*/
public function register() {
$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 );
}
}
diff --git a/src/Internal/ProductAttributesLookup/DataRegenerator.php b/src/Internal/ProductAttributesLookup/DataRegenerator.php
index e7a60bffd35..6eb52ada465 100644
--- a/src/Internal/ProductAttributesLookup/DataRegenerator.php
+++ b/src/Internal/ProductAttributesLookup/DataRegenerator.php
@@ -6,6 +6,7 @@
namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
+use Automattic\WooCommerce\Utilities\ArrayUtil;
defined( 'ABSPATH' ) || exit;
@@ -61,7 +62,7 @@ class DataRegenerator {
);
add_action(
- 'woocommerce_run_product_attribute_lookup_update_callback',
+ 'woocommerce_run_product_attribute_lookup_regeneration_callback',
function () {
$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).
*/
public function initiate_regeneration() {
+ $this->enable_or_disable_lookup_table_usage( false );
+
$this->delete_all_attributes_lookup_data();
$products_exist = $this->initialize_table_and_data();
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.
*
@@ -124,6 +118,7 @@ class DataRegenerator {
delete_option( 'woocommerce_attribute_lookup__enabled' );
delete_option( 'woocommerce_attribute_lookup__last_product_id_to_process' );
delete_option( 'woocommerce_attribute_lookup__last_products_page_processed' );
+ $this->data_store->unset_regeneration_in_progress_flag();
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( 'DROP TABLE IF EXISTS ' . $this->lookup_table_name );
@@ -170,6 +165,7 @@ CREATE TABLE ' . $this->lookup_table_name . '(
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_products_page_processed', 0 );
@@ -181,7 +177,7 @@ CREATE TABLE ' . $this->lookup_table_name . '(
* schedules the next step if necessary.
*/
private function run_regeneration_step_callback() {
- if ( ! $this->regeneration_is_in_progress() ) {
+ if ( ! $this->data_store->regeneration_is_in_progress() ) {
return;
}
@@ -200,7 +196,7 @@ CREATE TABLE ' . $this->lookup_table_name . '(
$queue = WC()->get_instance_of( \WC_Queue::class );
$queue->schedule_single(
WC()->call_function( 'time' ) + 1,
- 'woocommerce_run_product_attribute_lookup_update_callback',
+ 'woocommerce_run_product_attribute_lookup_regeneration_callback',
array(),
'woocommerce-db-updates'
);
@@ -233,7 +229,7 @@ CREATE TABLE ' . $this->lookup_table_name . '(
}
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 );
@@ -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_products_page_processed' );
update_option( 'woocommerce_attribute_lookup__enabled', 'no' );
- }
-
- /**
- * 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 );
+ $this->data_store->unset_regeneration_in_progress_flag();
}
/**
@@ -275,14 +259,14 @@ CREATE TABLE ' . $this->lookup_table_name . '(
return $tools_array;
}
- $lookup_table_exists = $this->lookup_table_exists();
- $generation_is_in_progress = $this->regeneration_is_in_progress();
+ $lookup_table_exists = $this->data_store->check_lookup_table_exists();
+ $generation_is_in_progress = $this->data_store->regeneration_is_in_progress();
// Regenerate table.
if ( $lookup_table_exists ) {
$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_button = __( 'Regenerate', 'woocommerce' );
} 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…', 'woocommerce' ),
+ );
+ }
+
if ( $generation_is_in_progress ) {
$entry['button'] = sprintf(
/* 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;
}
@@ -373,11 +338,19 @@ CREATE TABLE ' . $this->lookup_table_name . '(
* @throws \Exception The regeneration is already in progress.
*/
private function initiate_regeneration_from_tools_page() {
- if ( $this->regeneration_is_in_progress() ) {
- throw new \Exception( 'Product attributes lookup table is already regenerating.' );
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
+ 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.
*/
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." );
}
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" );
+ }
+ }
}
diff --git a/src/Internal/ProductAttributesLookup/Filterer.php b/src/Internal/ProductAttributesLookup/Filterer.php
index a42e3d804fe..6f34dc02b7a 100644
--- a/src/Internal/ProductAttributesLookup/Filterer.php
+++ b/src/Internal/ProductAttributesLookup/Filterer.php
@@ -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['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 );
$query['where'] = "
@@ -211,42 +213,51 @@ class Filterer {
$attributes_to_filter_by = \WC_Query::get_layered_nav_chosen_attributes();
if ( ! empty( $attributes_to_filter_by ) ) {
- $all_terms_to_filter_by = array();
- foreach ( $attributes_to_filter_by as $taxonomy => $data ) {
- $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 ) . ')';
+ $and_term_ids = array();
+ $or_term_ids = array();
- $count = count( $term_ids_to_filter_by );
- if ( 0 !== $count ) {
- $query['where'] .= ' AND product_or_parent_id IN (';
- if ( 'and' === $attributes_to_filter_by[ $taxonomy ]['query_type'] ) {
- $query['where'] .= "
- 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_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}
- )";
- }
+ foreach ( $attributes_to_filter_by as $taxonomy => $data ) {
+ $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'] ) ) );
+ if ( 'and' === $data['query_type'] ) {
+ $and_term_ids = array_merge( $and_term_ids, $term_ids_to_filter_by );
+ } else {
+ $or_term_ids = array_merge( $or_term_ids, $term_ids_to_filter_by );
}
}
+
+ 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 {
$query['where'] .= $in_stock_clause;
}
diff --git a/src/Internal/ProductAttributesLookup/LookupDataStore.php b/src/Internal/ProductAttributesLookup/LookupDataStore.php
index 22d6d432850..42471a7249d 100644
--- a/src/Internal/ProductAttributesLookup/LookupDataStore.php
+++ b/src/Internal/ProductAttributesLookup/LookupDataStore.php
@@ -14,6 +14,15 @@ defined( 'ABSPATH' ) || exit;
*/
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.
*
@@ -36,6 +45,101 @@ class LookupDataStore {
$this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup';
$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.
- * If a variable product is passed the information is updated for all of its variations.
+ * Insert/update the appropriate lookup table entries for a new or modified product or variation.
+ * 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.
- * @throws \Exception A variation object is passed.
+ * @param int|\WC_Product $product Product object or product id.
+ * @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 ) {
- // TODO: For now data is always deleted and fully regenerated, existing data should be updated instead.
+ public function on_product_changed( $product, $changeset = null ) {
+ if ( ! $this->check_lookup_table_exists() ) {
+ return;
+ }
if ( ! is_a( $product, \WC_Product::class ) ) {
$product = WC()->call_function( 'wc_get_product', $product );
}
- if ( $this->is_variation( $product ) ) {
- throw new \Exception( "LookupDataStore::update_data_for_product can't be called for variations." );
- }
-
- $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 );
+ $action = $this->get_update_action( $changeset );
+ if ( self::ACTION_NONE !== $action ) {
+ $this->maybe_schedule_update( $product->get_id(), $action );
}
}
/**
- * Delete all the lookup table entries for a given product
- * (entries are identified by the "parent_or_product_id" field)
+ * Schedule an update of the product attributes lookup table for a given product.
+ * 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.
*/
- private function delete_lookup_table_entries_for( int $product_id ) {
+ private function delete_data_for( int $product_id ) {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
$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
)
);
@@ -122,7 +410,7 @@ class LookupDataStore {
*
* @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 );
$has_stock = $product->is_in_stock();
$product_id = $product->get_id();
@@ -140,7 +428,7 @@ class LookupDataStore {
*
* @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 );
$variation_attributes_data = array_filter(
$product_attributes_data,
@@ -170,17 +458,53 @@ class LookupDataStore {
foreach ( $variation_attributes_data as $taxonomy => $data ) {
foreach ( $variations as $variation ) {
- $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 = $data['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 );
- }
- }
+ $this->insert_lookup_table_data_for_variation( $variation, $taxonomy, $main_product_id, $data['term_ids'], $term_ids_by_slug_cache );
+ }
+ }
+ }
+
+ /**
+ * Create all the necessary lookup data for a given variation.
+ *
+ * @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
}
+
+ /**
+ * 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' );
+ }
}
diff --git a/tests/Tools/FakeQueue.php b/tests/Tools/FakeQueue.php
index a8dd9151b55..cf60f524bf9 100644
--- a/tests/Tools/FakeQueue.php
+++ b/tests/Tools/FakeQueue.php
@@ -31,7 +31,7 @@ class FakeQueue implements \WC_Queue_Interface {
*
* @var array
*/
- public $methods_called = array();
+ private $methods_called = array();
// phpcs:disable Squiz.Commenting.FunctionComment.Missing
@@ -72,7 +72,13 @@ class FakeQueue implements \WC_Queue_Interface {
}
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
@@ -94,4 +100,21 @@ class FakeQueue implements \WC_Queue_Interface {
$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();
+ }
+
}
diff --git a/tests/php/src/Internal/ProductAttributesLookup/DataRegeneratorTest.php b/tests/php/src/Internal/ProductAttributesLookup/DataRegeneratorTest.php
index a40f97c42a2..a96bc96c0da 100644
--- a/tests/php/src/Internal/ProductAttributesLookup/DataRegeneratorTest.php
+++ b/tests/php/src/Internal/ProductAttributesLookup/DataRegeneratorTest.php
@@ -51,14 +51,14 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
$this->lookup_data_store = new class() extends LookupDataStore {
public $passed_products = array();
- public function update_data_for_product( $product ) {
+ public function create_data_for_product( $product ) {
$this->passed_products[] = $product;
}
};
// phpcs:enable Squiz.Commenting
// 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->reset_all_resolved();
@@ -128,10 +128,10 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
'method' => 'schedule_single',
'args' => array(),
'timestamp' => 1001,
- 'hook' => 'woocommerce_run_product_attribute_lookup_update_callback',
+ 'hook' => 'woocommerce_run_product_attribute_lookup_regeneration_callback',
'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 ) );
}
@@ -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_products_page_processed' ) );
$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->queue->methods_called = array();
+ $this->queue->clear_methods_called();
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( 8 ), $requested_products_pages );
@@ -198,10 +198,10 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
'method' => 'schedule_single',
'args' => array(),
'timestamp' => 1001,
- 'hook' => 'woocommerce_run_product_attribute_lookup_update_callback',
+ 'hook' => 'woocommerce_run_product_attribute_lookup_regeneration_callback',
'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 ) );
}
@@ -231,14 +231,14 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
);
$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->assertFalse( get_option( 'woocommerce_attribute_lookup__last_product_id_to_process' ) );
$this->assertFalse( get_option( 'woocommerce_attribute_lookup__last_products_page_processed' ) );
$this->assertEquals( 'no', get_option( 'woocommerce_attribute_lookup__enabled' ) );
- $this->assertEmpty( $this->queue->methods_called );
+ $this->assertEmpty( $this->queue->get_methods_called() );
}
}
diff --git a/tests/php/src/Internal/ProductAttributesLookup/FiltererTest.php b/tests/php/src/Internal/ProductAttributesLookup/FiltererTest.php
index e5a99eed25e..f0c4626a596 100644
--- a/tests/php/src/Internal/ProductAttributesLookup/FiltererTest.php
+++ b/tests/php/src/Internal/ProductAttributesLookup/FiltererTest.php
@@ -74,7 +74,7 @@ class FiltererTest extends \WC_Unit_Test_Case {
$child->delete( true );
} else {
$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();
}
+ /**
+ * 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.
*
@@ -165,7 +186,7 @@ class FiltererTest extends \WC_Unit_Test_Case {
$product->set_stock_status( $in_stock ? 'instock' : 'outofstock' );
- $product->save();
+ $this->save( $product );
if ( empty( $attribute_terms_by_name ) ) {
return $product;
@@ -234,7 +255,7 @@ class FiltererTest extends \WC_Unit_Test_Case {
)
);
- $product->save();
+ $this->save( $product );
$product_id = $product->get_id();
@@ -259,7 +280,7 @@ class FiltererTest extends \WC_Unit_Test_Case {
}
$variation->set_attributes( $attributes );
$variation->set_stock_status( $variation_data['in_stock'] ? 'instock' : 'outofstock' );
- $variation->save();
+ $this->save( $variation );
$variation_ids[] = $variation->get_id();
}
diff --git a/tests/php/src/Internal/ProductAttributesLookup/LookupDataStoreTest.php b/tests/php/src/Internal/ProductAttributesLookup/LookupDataStoreTest.php
index 58895eb4746..3f79beb72f4 100644
--- a/tests/php/src/Internal/ProductAttributesLookup/LookupDataStoreTest.php
+++ b/tests/php/src/Internal/ProductAttributesLookup/LookupDataStoreTest.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
use Automattic\WooCommerce\Testing\Tools\FakeQueue;
use Automattic\WooCommerce\Utilities\ArrayUtil;
+use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\FunctionsMockerHack;
/**
* Tests for the LookupDataStore class.
@@ -23,45 +24,62 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
*/
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.
*/
public function setUp() {
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.
- add_filter(
- 'woocommerce_queue_class',
- function() {
- return FakeQueue::class;
- }
- );
$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();
$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]
* [false]
*
* @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->set_id( 10 );
$this->set_product_attributes(
@@ -90,7 +108,7 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
$expected_in_stock = 0;
}
- $this->sut->update_data_for_product( $product );
+ $this->sut->create_data_for_product( $product );
$expected = 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() {
$products = array();
@@ -239,7 +257,7 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
$products[1001] = $variation_1;
$products[1002] = $variation_2;
- $this->sut->update_data_for_product( $product );
+ $this->sut->create_data_for_product( $product );
$expected = array(
// 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 ) );
}
+ /**
+ * @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:
*
@@ -380,4 +1140,73 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
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
+ }
}