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 ) { + ?> +
+ + + + +
+ -
- - - - $tool ) : ?> - - - - - - -
- -

-
- href="" class="button button-large "> -
-
+ + + + $tool ) : ?> + + + + + + +
+ +

+

'; + echo wp_kses_post( $selector['description'] ); + } + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo "  "; + } + ?> +

+
+ + type="submit" form="" class="button button-large" value="" /> +
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 + } }