Merge pull request #29778 from woocommerce/add/29608

Product attributes lookup table creation and filling
This commit is contained in:
Roy Ho 2021-05-10 07:41:39 -07:00 committed by GitHub
commit f9441dcc00
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1679 additions and 18 deletions

View File

@ -7,6 +7,7 @@
*/
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Utilities\ArrayUtil;
defined( 'ABSPATH' ) || exit;
@ -37,7 +38,8 @@ class WC_Admin_Status {
wp_die( 'Cannot load the REST API to access WC_REST_System_Status_Tools_Controller.' );
}
$tools = self::get_tools();
$tools = self::get_tools();
$tool_requires_refresh = false;
if ( ! empty( $_GET['action'] ) && ! empty( $_REQUEST['_wpnonce'] ) && wp_verify_nonce( wp_unslash( $_REQUEST['_wpnonce'] ), 'debug_action' ) ) { // WPCS: input var ok, sanitization ok.
$tools_controller = new WC_REST_System_Status_Tools_Controller();
@ -46,14 +48,16 @@ class WC_Admin_Status {
if ( array_key_exists( $action, $tools ) ) {
$response = $tools_controller->execute_tool( $action );
$tool = $tools[ $action ];
$tool = array(
$tool = $tools[ $action ];
$tool_requires_refresh = ArrayUtil::get_value_or_default( $tool, 'requires_refresh', false );
$tool = array(
'id' => $action,
'name' => $tool['name'],
'action' => $tool['button'],
'description' => $tool['desc'],
'disabled' => ArrayUtil::get_value_or_default( $tool, 'disabled', false ),
);
$tool = array_merge( $tool, $response );
$tool = array_merge( $tool, $response );
/**
* Fires after a WooCommerce system status tool has been executed.
@ -80,6 +84,10 @@ class WC_Admin_Status {
echo '<div class="updated inline"><p>' . esc_html__( 'Your changes have been saved.', 'woocommerce' ) . '</p></div>';
}
if ( $tool_requires_refresh ) {
$tools = self::get_tools();
}
include_once __DIR__ . '/views/html-admin-page-status-tools.php';
}

View File

@ -1,8 +1,12 @@
<?php
/**
* Admin View: Page - Status Tools
*
* @package WooCommerce
*/
use Automattic\WooCommerce\Utilities\ArrayUtil;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
@ -12,14 +16,14 @@ if ( ! defined( 'ABSPATH' ) ) {
<?php settings_fields( 'woocommerce_status_settings_fields' ); ?>
<table class="wc_status_table wc_status_table--tools widefat" cellspacing="0">
<tbody class="tools">
<?php foreach ( $tools as $action => $tool ) : ?>
<tr class="<?php echo sanitize_html_class( $action ); ?>">
<?php foreach ( $tools as $action_name => $tool ) : ?>
<tr class="<?php echo sanitize_html_class( $action_name ); ?>">
<th>
<strong class="name"><?php echo esc_html( $tool['name'] ); ?></strong>
<p class="description"><?php echo wp_kses_post( $tool['desc'] ); ?></p>
</th>
<td class="run-tool">
<a href="<?php echo wp_nonce_url( admin_url( 'admin.php?page=wc-status&tab=tools&action=' . $action ), 'debug_action' ); ?>" class="button button-large <?php echo esc_attr( $action ); ?>"><?php echo esc_html( $tool['button'] ); ?></a>
<a <?php echo ArrayUtil::is_truthy( $tool, 'disabled' ) ? 'disabled' : ''; ?> href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin.php?page=wc-status&tab=tools&action=' . $action_name ), 'debug_action' ) ); ?>" class="button button-large <?php echo esc_attr( $action_name ); ?>"><?php echo esc_html( $tool['button'] ); ?></a>
</td>
</tr>
<?php endforeach; ?>

View File

@ -10,6 +10,7 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\DownloadPermissionsAdjuster;
use Automattic\WooCommerce\Internal\AssignDefaultCategory;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Proxies\LegacyProxy;
/**
@ -209,6 +210,7 @@ final class WooCommerce {
// These classes set up hooks on instantiation.
wc_get_container()->get( DownloadPermissionsAdjuster::class );
wc_get_container()->get( AssignDefaultCategory::class );
wc_get_container()->get( DataRegenerator::class );
}
/**

View File

@ -143,7 +143,7 @@ class WC_REST_System_Status_Tools_V2_Controller extends WC_REST_Controller {
'button' => __( 'Clean up download permissions', 'woocommerce' ),
'desc' => __( 'This tool will delete expired download permissions and permissions with 0 remaining downloads.', 'woocommerce' ),
),
'regenerate_product_lookup_tables' => array(
'regenerate_product_lookup_tables' => array(
'name' => __( 'Product lookup tables', 'woocommerce' ),
'button' => __( 'Regenerate', 'woocommerce' ),
'desc' => __( 'This tool will regenerate product lookup table data. This process may take a while.', 'woocommerce' ),
@ -552,14 +552,14 @@ class WC_REST_System_Status_Tools_V2_Controller extends WC_REST_Controller {
$message = __( 'Template cache cleared.', 'woocommerce' );
} else {
$message = __( 'The active version of WooCommerce does not support template cache clearing.', 'woocommerce' );
$ran = false;
$ran = false;
}
break;
case 'verify_db_tables':
if ( ! method_exists( 'WC_Install', 'verify_base_tables' ) ) {
$message = __( 'You need WooCommerce 4.2 or newer to run this tool.', 'woocommerce' );
$ran = false;
$ran = false;
break;
}
// Try to manually create table again.
@ -567,9 +567,9 @@ class WC_REST_System_Status_Tools_V2_Controller extends WC_REST_Controller {
if ( 0 === count( $missing_tables ) ) {
$message = __( 'Database verified successfully.', 'woocommerce' );
} else {
$message = __( 'Verifying database... One or more tables are still missing: ', 'woocommerce' );
$message = __( 'Verifying database... One or more tables are still missing: ', 'woocommerce' );
$message .= implode( ', ', $missing_tables );
$ran = false;
$ran = false;
}
break;
@ -577,11 +577,35 @@ class WC_REST_System_Status_Tools_V2_Controller extends WC_REST_Controller {
$tools = $this->get_tools();
if ( isset( $tools[ $tool ]['callback'] ) ) {
$callback = $tools[ $tool ]['callback'];
$return = call_user_func( $callback );
if ( is_string( $return ) ) {
try {
$return = call_user_func( $callback );
} catch ( Exception $exception ) {
$return = $exception;
}
if ( is_a( $return, Exception::class ) ) {
$callback_string = $this->get_printable_callback_name( $callback, $tool );
$ran = false;
/* translators: %1$s: callback string, %2$s: error message */
$message = sprintf( __( 'There was an error calling %1$s: %2$s', 'woocommerce' ), $callback_string, $return->getMessage() );
$logger = wc_get_logger();
$logger->error(
sprintf(
'Error running debug tool %s: %s',
$tool,
$return->getMessage()
),
array(
'source' => 'run-debug-tool',
'tool' => $tool,
'callback' => $callback,
'error' => $return,
)
);
} elseif ( is_string( $return ) ) {
$message = $return;
} elseif ( false === $return ) {
$callback_string = is_array( $callback ) ? get_class( $callback[0] ) . '::' . $callback[1] : $callback;
$callback_string = $this->get_printable_callback_name( $callback, $tool );
$ran = false;
/* translators: %s: callback string */
$message = sprintf( __( 'There was an error calling %s', 'woocommerce' ), $callback_string );
@ -600,4 +624,22 @@ class WC_REST_System_Status_Tools_V2_Controller extends WC_REST_Controller {
'message' => $message,
);
}
/**
* Get a printable name for a callback.
*
* @param mixed $callback The callback to get a name for.
* @param string $default The default name, to be returned when the callback is an inline function.
* @return string A printable name for the callback.
*/
private function get_printable_callback_name( $callback, $default ) {
if ( is_array( $callback ) ) {
return get_class( $callback[0] ) . '::' . $callback[1];
}
if ( is_string( $callback ) ) {
return $callback;
}
return $default;
}
}

View File

@ -8,6 +8,7 @@ namespace Automattic\WooCommerce;
use Automattic\WooCommerce\Internal\DependencyManagement\ExtendedContainer;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\DownloadPermissionsAdjusterServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\AssignDefaultCategoryServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\ProductAttributesLookupServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\ProxiesServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\ThemeManagementServiceProvider;
@ -35,10 +36,11 @@ final class Container implements \Psr\Container\ContainerInterface {
* @var string[]
*/
private $service_providers = array(
AssignDefaultCategoryServiceProvider::class,
DownloadPermissionsAdjusterServiceProvider::class,
ProductAttributesLookupServiceProvider::class,
ProxiesServiceProvider::class,
ThemeManagementServiceProvider::class,
DownloadPermissionsAdjusterServiceProvider::class,
AssignDefaultCategoryServiceProvider::class,
);
/**

View File

@ -81,7 +81,7 @@ class ExtendedContainer extends BaseContainer {
}
$concrete_class = $this->get_class_from_concrete( $concrete );
if ( isset( $concrete_class ) && ! $this->is_class_allowed( $concrete_class ) ) {
if ( isset( $concrete_class ) && ! $this->is_class_allowed( $concrete_class ) && ! $this->is_anonymous_class( $concrete_class ) ) {
throw new ContainerException( "You cannot use concrete '$concrete_class', only classes in the {$this->woocommerce_namespace} namespace are allowed." );
}
@ -149,4 +149,14 @@ class ExtendedContainer extends BaseContainer {
protected function is_class_allowed( string $class_name ): bool {
return StringUtil::starts_with( $class_name, $this->woocommerce_namespace, false ) || in_array( $class_name, $this->registration_whitelist, true );
}
/**
* Check if a class name corresponds to an anonymous class.
*
* @param string $class_name The class name to check.
* @return bool True if the name corresponds to an anonymous class.
*/
protected function is_anonymous_class( string $class_name ): bool {
return StringUtil::starts_with( $class_name, 'class@anonymous' );
}
}

View File

@ -0,0 +1,33 @@
<?php
/**
* ProductAttributesLookupServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
/**
* Service provider for the ProductAttributesLookupServiceProvider namespace.
*/
class ProductAttributesLookupServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
DataRegenerator::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( DataRegenerator::class )->addArgument( LookupDataStore::class );
$this->share( LookupDataStore::class );
}
}

View File

@ -0,0 +1,396 @@
<?php
/**
* DataRegenerator class file.
*/
namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
defined( 'ABSPATH' ) || exit;
/**
* This class handles the (re)generation of the product attributes lookup table.
* It schedules the regeneration in small product batches by itself, so it can be used outside the
* regular WooCommerce data regenerations mechanism.
*
* After the regeneration is completed a wp_wc_product_attributes_lookup table will exist with entries for
* all the products that existed when initiate_regeneration was invoked; entries for products created after that
* are supposed to be created/updated by the appropriate data store classes (or by the code that uses
* the data store classes) whenever a product is created/updated.
*
* Additionally, after the regeneration is completed a 'woocommerce_attribute_lookup__enabled' option
* with a value of 'no' will have been created.
*
* This class also adds two entries to the Status - Tools menu: one for manually regenerating the table contents,
* and another one for enabling or disabling the actual lookup table usage.
*/
class DataRegenerator {
const PRODUCTS_PER_GENERATION_STEP = 10;
/**
* The data store to use.
*
* @var LookupDataStore
*/
private $data_store;
/**
* The lookup table name.
*
* @var string
*/
private $lookup_table_name;
/**
* DataRegenerator constructor.
*/
public function __construct() {
global $wpdb;
$this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup';
add_filter(
'woocommerce_debug_tools',
function( $tools ) {
return $this->add_initiate_regeneration_entry_to_tools_array( $tools );
},
1,
999
);
add_action(
'woocommerce_run_product_attribute_lookup_update_callback',
function () {
$this->run_regeneration_step_callback();
}
);
}
/**
* Class initialization, invoked by the DI container.
*
* @internal
* @param LookupDataStore $data_store The data store to use.
*/
final public function init( LookupDataStore $data_store ) {
$this->data_store = $data_store;
}
/**
* Initialize the regeneration procedure:
* deletes the lookup table and related options if they exist,
* then it creates the table and runs the first step of the regeneration process.
*
* This is the method that should be used as a callback for a data regeneration in wc-update-functions, e.g.:
*
* function wc_update_XX_regenerate_product_attributes_lookup_table() {
* wc_get_container()->get(DataRegenerator::class)->initiate_regeneration();
* return false;
* }
*
* (Note how we are returning "false" since the class handles the step scheduling by itself).
*/
public function initiate_regeneration() {
$this->delete_all_attributes_lookup_data();
$products_exist = $this->initialize_table_and_data();
if ( $products_exist ) {
$this->enqueue_regeneration_step_run();
} else {
$this->finalize_regeneration();
}
}
/**
* 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.
*
* Shortcut to run this method in case the debug tools UI isn't available or for quick debugging:
*
* wp eval "wc_get_container()->get(Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator::class)->delete_all_attributes_lookup_data();"
*/
public function delete_all_attributes_lookup_data() {
global $wpdb;
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' );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( 'DROP TABLE IF EXISTS ' . $this->lookup_table_name );
}
/**
* Create the lookup table and initialize the options that will be temporarily used
* while the regeneration is in progress.
*
* @return bool True if there's any product at all in the database, false otherwise.
*/
private function initialize_table_and_data() {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
'
CREATE TABLE ' . $this->lookup_table_name . '(
product_id bigint(20) NOT NULL,
product_or_parent_id bigint(20) NOT NULL,
taxonomy varchar(32) NOT NULL,
term_id bigint(20) NOT NULL,
is_variation_attribute tinyint(1) NOT NULL,
in_stock tinyint(1) NOT NULL
);
'
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
$last_existing_product_id =
WC()->call_function(
'wc_get_products',
array(
'return' => 'ids',
'limit' => 1,
'orderby' => array(
'ID' => 'DESC',
),
)
);
if ( ! $last_existing_product_id ) {
// No products exist, nothing to (re)generate.
return false;
}
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 );
return true;
}
/**
* Action scheduler callback, performs one regeneration step and then
* schedules the next step if necessary.
*/
private function run_regeneration_step_callback() {
if ( ! $this->regeneration_is_in_progress() ) {
return;
}
$result = $this->do_regeneration_step();
if ( $result ) {
$this->enqueue_regeneration_step_run();
} else {
$this->finalize_regeneration();
}
}
/**
* Enqueue one regeneration step in action scheduler.
*/
private function enqueue_regeneration_step_run() {
$queue = WC()->get_instance_of( \WC_Queue::class );
$queue->schedule_single(
WC()->call_function( 'time' ) + 1,
'woocommerce_run_product_attribute_lookup_update_callback',
array(),
'woocommerce-db-updates'
);
}
/**
* Perform one regeneration step: grabs a chunk of products and creates
* the appropriate entries for them in the lookup table.
*
* @return bool True if more steps need to be run, false otherwise.
*/
private function do_regeneration_step() {
$last_products_page_processed = get_option( 'woocommerce_attribute_lookup__last_products_page_processed' );
$current_products_page = (int) $last_products_page_processed + 1;
$product_ids = WC()->call_function(
'wc_get_products',
array(
'limit' => self::PRODUCTS_PER_GENERATION_STEP,
'page' => $current_products_page,
'orderby' => array(
'ID' => 'ASC',
),
'return' => 'ids',
)
);
if ( ! $product_ids ) {
return false;
}
foreach ( $product_ids as $id ) {
$this->data_store->update_data_for_product( $id );
}
update_option( 'woocommerce_attribute_lookup__last_products_page_processed', $current_products_page );
$last_product_id_to_process = get_option( 'woocommerce_attribute_lookup__last_product_id_to_process' );
return end( $product_ids ) < $last_product_id_to_process;
}
/**
* Cleanup/final option setup after the regeneration has been completed.
*/
private function finalize_regeneration() {
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 );
}
/**
* Add a 'Regenerate product attributes lookup table' entry to the Status - Tools page.
*
* @param array $tools_array The tool definitions array that is passed ro the woocommerce_debug_tools filter.
* @return array The tools array with the entry added.
*/
private function add_initiate_regeneration_entry_to_tools_array( array $tools_array ) {
if ( ! $this->data_store->is_feature_visible() ) {
return $tools_array;
}
$lookup_table_exists = $this->lookup_table_exists();
$generation_is_in_progress = $this->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_return = __( 'Product attributes lookup table data is regenerating', 'woocommerce' );
$generate_item_button = __( 'Regenerate', 'woocommerce' );
} else {
$generate_item_name = __( 'Create and fill product attributes lookup table', 'woocommerce' );
$generate_item_desc = __( 'This tool will create the product attributes lookup table data and fill it with existing products data. This process may take a while.', 'woocommerce' );
$generate_item_return = __( 'Product attributes lookup table is being filled', 'woocommerce' );
$generate_item_button = __( 'Create', 'woocommerce' );
}
$entry = array(
'name' => $generate_item_name,
'desc' => $generate_item_desc,
'requires_refresh' => true,
'callback' => function() use ( $generate_item_return ) {
$this->initiate_regeneration_from_tools_page();
return $generate_item_return;
},
);
if ( $generation_is_in_progress ) {
$entry['button'] = sprintf(
/* translators: %d: How many products have been processed so far. */
__( 'Filling in progress (%d)', 'woocommerce' ),
get_option( 'woocommerce_attribute_lookup__last_products_page_processed', 0 ) * self::PRODUCTS_PER_GENERATION_STEP
);
$entry['disabled'] = true;
} else {
$entry['button'] = $generate_item_button;
}
$tools_array['regenerate_product_attributes_lookup_table'] = $entry;
if ( $lookup_table_exists ) {
// Delete the table.
$tools_array['delete_product_attributes_lookup_table'] = array(
'name' => __( 'Delete the product attributes lookup table', 'woocommerce' ),
'desc' => sprintf(
'<strong class="red">%1$s</strong> %2$s',
__( 'Note:', 'woocommerce' ),
__( 'This will delete the product attributes lookup table. You can create it again with the "Create and fill product attributes lookup table" tool.', 'woocommerce' )
),
'button' => __( 'Delete', 'woocommerce' ),
'requires_refresh' => true,
'callback' => function () {
$this->delete_all_attributes_lookup_data();
return __( 'Product attributes lookup table has been deleted.', 'woocommerce' );
},
);
}
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;
}
/**
* Callback to initiate the regeneration process from the Status - Tools page.
*
* @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.' );
}
$this->initiate_regeneration();
}
/**
* Enable or disable the actual lookup table usage.
*
* @param bool $enable True to enable, false to disable.
* @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() ) {
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' );
}
}

View File

@ -0,0 +1,332 @@
<?php
/**
* LookupDataStore class file.
*/
namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;
use Automattic\WooCommerce\Utilities\ArrayUtil;
defined( 'ABSPATH' ) || exit;
/**
* Data store class for the product attributes lookup table.
*/
class LookupDataStore {
/**
* The lookup table name.
*
* @var string
*/
private $lookup_table_name;
/**
* Is the feature visible?
*
* @var bool
*/
private $is_feature_visible;
/**
* LookupDataStore constructor. Makes the feature hidden by default.
*/
public function __construct() {
global $wpdb;
$this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup';
$this->is_feature_visible = false;
}
/**
* Checks if the feature is visible (so that dedicated entries will be added to the debug tools page).
*
* @return bool True if the feature is visible.
*/
public function is_feature_visible() {
return $this->is_feature_visible;
}
/**
* Makes the feature visible, so that dedicated entries will be added to the debug tools page.
*/
public function show_feature() {
$this->is_feature_visible = true;
}
/**
* Hides the feature, so that no entries will be added to the debug tools page.
*/
public function hide_feature() {
$this->is_feature_visible = false;
}
/**
* 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.
*
* @param int|WC_Product $product Product object or id.
* @throws \Exception A variation object is passed.
*/
public function update_data_for_product( $product ) {
// TODO: For now data is always deleted and fully regenerated, existing data should be updated instead.
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 );
}
}
/**
* Delete all the lookup table entries for a given product
* (entries are identified by the "parent_or_product_id" field)
*
* @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 ) {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
$wpdb->prepare(
'DELETE FROM ' . $this->lookup_table_name . ' WHERE product_or_parent_id = %d',
$product_id
)
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Create lookup table entries for a simple (non variable) product.
* Assumes that no entries exist yet.
*
* @param \WC_Product $product The product to create the entries for.
*/
private function create_lookup_table_entries_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();
foreach ( $product_attributes_data as $taxonomy => $data ) {
$term_ids = $data['term_ids'];
foreach ( $term_ids as $term_id ) {
$this->insert_lookup_table_data( $product_id, $product_id, $taxonomy, $term_id, false, $has_stock );
}
}
}
/**
* Create lookup table entries for a variable product.
* Assumes that no entries exist yet.
*
* @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 ) {
$product_attributes_data = $this->get_attribute_taxonomies( $product );
$variation_attributes_data = array_filter(
$product_attributes_data,
function( $item ) {
return $item['used_for_variations'];
}
);
$non_variation_attributes_data = array_filter(
$product_attributes_data,
function( $item ) {
return ! $item['used_for_variations'];
}
);
$main_product_has_stock = $product->is_in_stock();
$main_product_id = $product->get_id();
foreach ( $non_variation_attributes_data as $taxonomy => $data ) {
$term_ids = $data['term_ids'];
foreach ( $term_ids as $term_id ) {
$this->insert_lookup_table_data( $main_product_id, $main_product_id, $taxonomy, $term_id, false, $main_product_has_stock );
}
}
$term_ids_by_slug_cache = $this->get_term_ids_by_slug_cache( array_keys( $variation_attributes_data ) );
$variations = $this->get_variations_of( $product );
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 );
}
}
}
}
}
/**
* Get a cache of term ids by slug for a set of taxonomies, with this format:
*
* [
* 'taxonomy' => [
* 'slug_1' => id_1,
* 'slug_2' => id_2,
* ...
* ], ...
* ]
*
* @param array $taxonomies List of taxonomies to build the cache for.
* @return array A dictionary of taxonomies => dictionary of term slug => term id.
*/
private function get_term_ids_by_slug_cache( $taxonomies ) {
$result = array();
foreach ( $taxonomies as $taxonomy ) {
$terms = WC()->call_function(
'get_terms',
array(
'taxonomy' => $taxonomy,
'hide_empty' => false,
'fields' => 'id=>slug',
)
);
$result[ $taxonomy ] = array_flip( $terms );
}
return $result;
}
/**
* Get the id of the term that defines a variation for a given taxonomy,
* or null if there's no such defining id (for variations having "Any <taxonomy>" as the definition)
*
* @param \WC_Product_Variation $variation The variation to get the defining term id for.
* @param string $taxonomy The taxonomy to get the defining term id for.
* @param array $term_ids_by_slug_cache A term ids by slug as generated by get_term_ids_by_slug_cache.
* @return int|null The term id, or null if there's no defining id for that taxonomy in that variation.
*/
private function get_variation_definition_term_id( \WC_Product_Variation $variation, string $taxonomy, array $term_ids_by_slug_cache ) {
$variation_attributes = $variation->get_attributes();
$term_slug = ArrayUtil::get_value_or_default( $variation_attributes, $taxonomy );
if ( $term_slug ) {
return $term_ids_by_slug_cache[ $taxonomy ][ $term_slug ];
} else {
return null;
}
}
/**
* Get the variations of a given variable product.
*
* @param \WC_Product_Variable $product The product to get the variations for.
* @return array An array of WC_Product_Variation objects.
*/
private function get_variations_of( \WC_Product_Variable $product ) {
$variation_ids = $product->get_children();
return array_map(
function( $id ) {
return WC()->call_function( 'wc_get_product', $id );
},
$variation_ids
);
}
/**
* Check if a given product is a variable product.
*
* @param \WC_Product $product The product to check.
* @return bool True if it's a variable product, false otherwise.
*/
private function is_variable_product( \WC_Product $product ) {
return is_a( $product, \WC_Product_Variable::class );
}
/**
* Check if a given product is a variation.
*
* @param \WC_Product $product The product to check.
* @return bool True if it's a variation, false otherwise.
*/
private function is_variation( \WC_Product $product ) {
return is_a( $product, \WC_Product_Variation::class );
}
/**
* Return the list of taxonomies used for variations on a product together with
* the associated term ids, with the following format:
*
* [
* 'taxonomy_name' =>
* [
* 'term_ids' => [id, id, ...],
* 'used_for_variations' => true|false
* ], ...
* ]
*
* @param \WC_Product $product The product to get the attribute taxonomies for.
* @return array Information about the attribute taxonomies of the product.
*/
private function get_attribute_taxonomies( \WC_Product $product ) {
$product_attributes = $product->get_attributes();
$result = array();
foreach ( $product_attributes as $taxonomy_name => $attribute_data ) {
if ( ! $attribute_data->get_id() ) {
// Custom product attribute, not suitable for attribute-based filtering.
continue;
}
$result[ $taxonomy_name ] = array(
'term_ids' => $attribute_data->get_options(),
'used_for_variations' => $attribute_data->get_variation(),
);
}
return $result;
}
/**
* 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
}
}

View File

@ -44,5 +44,28 @@ class ArrayUtil {
return $value;
}
/**
* Checks if a given key exists in an array and its value can be evaluated as 'true'.
*
* @param array $array The array to check.
* @param string $key The key for the value to check.
* @return bool True if the key exists in the array and the value can be evaluated as 'true'.
*/
public static function is_truthy( array $array, string $key ) {
return isset( $array[ $key ] ) && $array[ $key ];
}
/**
* Gets the value for a given key from an array, or a default value if the key doesn't exist in the array.
*
* @param array $array The array to get the value from.
* @param string $key The key to use to retrieve the value.
* @param null $default The default value to return if the key doesn't exist in the array.
* @return mixed|null The value for the key, or the default value passed.
*/
public static function get_value_or_default( array $array, string $key, $default = null ) {
return isset( $array[ $key ] ) ? $array[ $key ] : $default;
}
}

97
tests/Tools/FakeQueue.php Normal file
View File

@ -0,0 +1,97 @@
<?php
/**
* FakeQueue class file.
*
* @package WooCommerce\Testing\Tools
*/
namespace Automattic\WooCommerce\Testing\Tools;
/**
* Fake scheduled actions queue for unit tests, it just records all the method calls
* in a publicly accessible $methods_called property.
*
* To use:
*
* 1. The production class must get an instance of the queue in this way:
*
* WC()->get_instance_of(\WC_Queue::class)
*
* 2. Add the following in the setUp() method of the unit tests class:
*
* $this->register_legacy_proxy_class_mocks([\WC_Queue::class => new FakeQueue()]);
*
* 3. Get the instance of the fake queue with $this->get_legacy_instance_of(\WC_Queue::class)
* and check its methods_called field as appropriate.
*/
class FakeQueue implements \WC_Queue_Interface {
/**
* Records all the method calls to this instance.
*
* @var array
*/
public $methods_called = array();
// phpcs:disable Squiz.Commenting.FunctionComment.Missing
public function add( $hook, $args = array(), $group = '' ) {
// TODO: Implement add() method.
}
public function schedule_single( $timestamp, $hook, $args = array(), $group = '' ) {
$this->add_to_methods_called(
'schedule_single',
$args,
$group,
array(
'timestamp' => $timestamp,
'hook' => $hook,
)
);
}
public function schedule_recurring( $timestamp, $interval_in_seconds, $hook, $args = array(), $group = '' ) {
// TODO: Implement schedule_recurring() method.
}
public function schedule_cron( $timestamp, $cron_schedule, $hook, $args = array(), $group = '' ) {
// TODO: Implement schedule_cron() method.
}
public function cancel( $hook, $args = array(), $group = '' ) {
// TODO: Implement cancel() method.
}
public function cancel_all( $hook, $args = array(), $group = '' ) {
// TODO: Implement cancel_all() method.
}
public function get_next( $hook, $args = null, $group = '' ) {
// TODO: Implement get_next() method.
}
public function search( $args = array(), $return_format = OBJECT ) {
// TODO: Implement search() method.
}
// phpcs:enable Squiz.Commenting.FunctionComment.Missing
/**
* Registers a method call for this instance.
*
* @param string $method Name of the invoked method.
* @param array $args Arguments passed in '$args' to the method call.
* @param string $group Group name passed in '$group' to the method call.
* @param array $extra_args Any extra information to store about the method call.
*/
private function add_to_methods_called( $method, $args, $group, $extra_args = array() ) {
$value = array(
'method' => $method,
'args' => $args,
'group' => $group,
);
$this->methods_called[] = array_merge( $value, $extra_args );
}
}

View File

@ -103,6 +103,20 @@ class ExtendedContainerTest extends \WC_Unit_Test_Case {
$this->assertSame( $instance_2, $this->sut->get( DependencyClass::class ) );
}
/**
* @testdox 'replace' should allow to replace existing registrations with anonymous classes.
*/
public function test_replace_allows_replacing_existing_registrations_with_anonymous_classes() {
$instance_1 = new DependencyClass();
$instance_2 = new class() extends DependencyClass {};
$this->sut->add( DependencyClass::class, $instance_1, true );
$this->assertSame( $instance_1, $this->sut->get( DependencyClass::class ) );
$this->sut->replace( DependencyClass::class, $instance_2, true );
$this->assertSame( $instance_2, $this->sut->get( DependencyClass::class ) );
}
/**
* @testdox 'reset_all_resolved' should discard cached resolutions for classes registered as 'shared'.
*/

View File

@ -0,0 +1,244 @@
<?php
/**
* DataRegeneratorTest class file.
*/
namespace Automattic\WooCommerce\Tests\Internal\ProductAttributesLookup;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
use Automattic\WooCommerce\Testing\Tools\FakeQueue;
/**
* Tests for the DataRegenerator class.
* @package Automattic\WooCommerce\Tests\Internal\ProductAttributesLookup
*/
class DataRegeneratorTest extends \WC_Unit_Test_Case {
/**
* The system under test.
*
* @var DataRegenerator
*/
private $sut;
/**
* @var LookupDataStore
*/
private $lookup_data_store;
/**
* @var string
*/
private $lookup_table_name;
/**
* @var FakeQueue
*/
private $queue;
/**
* Runs before each test.
*/
public function setUp() {
global $wpdb;
parent::setUp();
$this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup';
// phpcs:disable Squiz.Commenting
$this->lookup_data_store = new class() extends LookupDataStore {
public $passed_products = array();
public function update_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' );
$container = wc_get_container();
$container->reset_all_resolved();
$container->replace( LookupDataStore::class, $this->lookup_data_store );
$this->sut = $container->get( DataRegenerator::class );
$this->register_legacy_proxy_class_mocks(
array(
\WC_Queue::class => new FakeQueue(),
)
);
$this->queue = $this->get_legacy_instance_of( \WC_Queue::class );
}
/**
* @testdox `initiate_regeneration` creates the lookup table, deleting it first if it already existed.
*
* @testWith [false]
* [true]
*
* @param bool $previously_existing True to create a lookup table beforehand.
*/
public function test_initiate_regeneration_creates_looukp_table( $previously_existing ) {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( 'DROP TABLE IF EXISTS ' . $this->lookup_table_name );
if ( $previously_existing ) {
$wpdb->query( 'CREATE TABLE ' . $this->lookup_table_name . ' (foo int);' );
}
$this->sut->initiate_regeneration();
// Try to insert a row to verify that the table exists.
// We can't use the regular table existence detection mechanisms because PHPUnit creates all tables as temporary.
$wpdb->query( 'INSERT INTO ' . $this->lookup_table_name . " VALUES (1, 1, 'taxonomy', 1, 1, 1 )" );
$value = $wpdb->get_var( 'SELECT product_id FROM ' . $this->lookup_table_name . ' LIMIT 1' );
$this->assertEquals( 1, $value );
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}
/**
* @testdox `initiate_regeneration` initializes the transient options, and enqueues the first step for time()+1.
*/
public function test_initiate_regeneration_initializes_temporary_options_and_enqueues_regeneration_step() {
$this->register_legacy_proxy_function_mocks(
array(
'wc_get_products' => function( $args ) {
return array( 100 );
},
'time' => function() {
return 1000;
},
)
);
$this->sut->initiate_regeneration();
$this->assertEquals( 100, get_option( 'woocommerce_attribute_lookup__last_product_id_to_process' ) );
$this->assertEquals( 0, get_option( 'woocommerce_attribute_lookup__last_products_page_processed' ) );
$this->assertFalse( get_option( 'woocommerce_attribute_lookup__enabled' ) );
$expected_enqueued = array(
'method' => 'schedule_single',
'args' => array(),
'timestamp' => 1001,
'hook' => 'woocommerce_run_product_attribute_lookup_update_callback',
'group' => 'woocommerce-db-updates',
);
$actual_enqueued = current( $this->queue->methods_called );
$this->assertEquals( sort( $expected_enqueued ), sort( $actual_enqueued ) );
}
/**
* @testdox `initiate_regeneration` finalizes the regeneration process without enqueueing any step if the db is empty.
*
* @testWith [false]
* [[]]
*
* @param mixed $get_products_result Result from wc_get_products.
*/
public function test_initiate_regeneration_does_not_enqueues_regeneration_step_when_no_products( $get_products_result ) {
$this->register_legacy_proxy_function_mocks(
array(
'wc_get_products' => function( $args ) use ( $get_products_result ) {
return $get_products_result;
},
)
);
$this->sut->initiate_regeneration();
$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 );
}
/**
* @testdox `initiate_regeneration` processes one chunk of products IDs and enqueues next step if there are more products available.
*/
public function test_initiate_regeneration_correctly_processes_ids_and_enqueues_next_step() {
$requested_products_pages = array();
$this->register_legacy_proxy_function_mocks(
array(
'wc_get_products' => function( $args ) use ( &$requested_products_pages ) {
if ( 'DESC' === current( $args['orderby'] ) ) {
return array( 100 );
} else {
$requested_products_pages[] = $args['page'];
return array( 1, 2, 3 );
}
},
'time' => function() {
return 1000;
},
)
);
$this->sut->initiate_regeneration();
$this->queue->methods_called = array();
update_option( 'woocommerce_attribute_lookup__last_products_page_processed', 7 );
do_action( 'woocommerce_run_product_attribute_lookup_update_callback' );
$this->assertEquals( array( 1, 2, 3 ), $this->lookup_data_store->passed_products );
$this->assertEquals( array( 8 ), $requested_products_pages );
$this->assertEquals( 8, get_option( 'woocommerce_attribute_lookup__last_products_page_processed' ) );
$expected_enqueued = array(
'method' => 'schedule_single',
'args' => array(),
'timestamp' => 1001,
'hook' => 'woocommerce_run_product_attribute_lookup_update_callback',
'group' => 'woocommerce-db-updates',
);
$actual_enqueued = current( $this->queue->methods_called );
$this->assertEquals( sort( $expected_enqueued ), sort( $actual_enqueued ) );
}
/**
* @testdox `initiate_regeneration` finishes regeneration when the max product id is reached or no more products are returned.
*
* @testWith [[98,99,100]]
* [[99,100,101]]
* [[]]
*
* @param array $product_ids The products ids that wc_get_products will return.
*/
public function test_initiate_regeneration_finishes_when_no_more_products_available( $product_ids ) {
$requested_products_pages = array();
$this->register_legacy_proxy_function_mocks(
array(
'wc_get_products' => function( $args ) use ( &$requested_products_pages, $product_ids ) {
if ( 'DESC' === current( $args['orderby'] ) ) {
return array( 100 );
} else {
$requested_products_pages[] = $args['page'];
return $product_ids;
}
},
)
);
$this->sut->initiate_regeneration();
$this->queue->methods_called = array();
do_action( 'woocommerce_run_product_attribute_lookup_update_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 );
}
}

View File

@ -0,0 +1,383 @@
<?php
/**
* LookupDataStoreTest class file.
*/
namespace Automattic\WooCommerce\Tests\Internal\ProductAttributesLookup;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
use Automattic\WooCommerce\Testing\Tools\FakeQueue;
use Automattic\WooCommerce\Utilities\ArrayUtil;
/**
* Tests for the LookupDataStore class.
* @package Automattic\WooCommerce\Tests\Internal\ProductAttributesLookup
*/
class LookupDataStoreTest extends \WC_Unit_Test_Case {
/**
* The system under test.
*
* @var LookupDataStore
*/
private $sut;
/**
* Runs before each test.
*/
public function setUp() {
global $wpdb;
$this->sut = new LookupDataStore();
// 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.
*/
public function test_update_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->sut->update_data_for_product( $product );
}
/**
* @testdox `test_update_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 ) {
$product = new \WC_Product_Simple();
$product->set_id( 10 );
$this->set_product_attributes(
$product,
array(
'pa_attribute_1' => array(
'id' => 100,
'options' => array( 51, 52 ),
),
'pa_attribute_2' => array(
'id' => 200,
'options' => array( 73, 74 ),
),
'pa_custom_attribute' => array(
'id' => 0,
'options' => array( 'foo', 'bar' ),
),
)
);
if ( $in_stock ) {
$product->set_stock_status( 'instock' );
$expected_in_stock = 1;
} else {
$product->set_stock_status( 'outofstock' );
$expected_in_stock = 0;
}
$this->sut->update_data_for_product( $product );
$expected = array(
array(
'product_id' => 10,
'product_or_parent_id' => 10,
'taxonomy' => 'pa_attribute_1',
'term_id' => 51,
'in_stock' => $expected_in_stock,
'is_variation_attribute' => 0,
),
array(
'product_id' => 10,
'product_or_parent_id' => 10,
'taxonomy' => 'pa_attribute_1',
'term_id' => 52,
'in_stock' => $expected_in_stock,
'is_variation_attribute' => 0,
),
array(
'product_id' => 10,
'product_or_parent_id' => 10,
'taxonomy' => 'pa_attribute_2',
'term_id' => 73,
'in_stock' => $expected_in_stock,
'is_variation_attribute' => 0,
),
array(
'product_id' => 10,
'product_or_parent_id' => 10,
'taxonomy' => 'pa_attribute_2',
'term_id' => 74,
'in_stock' => $expected_in_stock,
'is_variation_attribute' => 0,
),
);
$actual = $this->get_lookup_table_data();
$this->assertEquals( sort( $expected ), sort( $actual ) );
}
/**
* @testdox `test_update_data_for_product` creates the appropriate entries for variable products.
*/
public function test_update_data_for_variable_product() {
$products = array();
/**
* Create one normal attribute and two attributes used to define variations,
* with 4 terms each.
*/
$this->register_legacy_proxy_function_mocks(
array(
'get_terms' => function( $args ) use ( &$invokations_of_get_terms ) {
switch ( $args['taxonomy'] ) {
case 'non-variation-attribute':
return array(
10 => 'term_10',
20 => 'term_20',
30 => 'term_30',
40 => 'term_40',
);
case 'variation-attribute-1':
return array(
50 => 'term_50',
60 => 'term_60',
70 => 'term_70',
80 => 'term_80',
);
case 'variation-attribute-2':
return array(
90 => 'term_90',
100 => 'term_100',
110 => 'term_110',
120 => 'term_120',
);
default:
throw new \Exception( "Unexpected call to 'get_terms'" );
}
},
'wc_get_product' => function( $id ) use ( &$products ) {
return $products[ $id ];
},
)
);
/**
* Create a variable product with:
* - 3 of the 4 values of the regular attribute.
* - A custom product attribute.
* - The two variation attributes, with 3 of the 4 terms for each one.
* - Variation 1 having one value for each of the variation attributes.
* - Variation 2 having one value for variation-attribute-1
* but none for variation-attribute-2 (so the value for that one is "Any").
*/
$product = new \WC_Product_Variable();
$product->set_id( 1000 );
$this->set_product_attributes(
$product,
array(
'non-variation-attribute' => array(
'id' => 100,
'options' => array( 10, 20, 30 ),
),
'pa_custom_attribute' => array(
'id' => 0,
'options' => array( 'foo', 'bar' ),
),
'variation-attribute-1' => array(
'id' => 200,
'options' => array( 50, 60, 70 ),
'variation' => true,
),
'variation-attribute-2' => array(
'id' => 300,
'options' => array( 90, 100, 110 ),
'variation' => true,
),
)
);
$product->set_stock_status( 'instock' );
$variation_1 = new \WC_Product_Variation();
$variation_1->set_id( 1001 );
$variation_1->set_attributes(
array(
'variation-attribute-1' => 'term_50',
'variation-attribute-2' => 'term_90',
)
);
$variation_1->set_stock_status( 'instock' );
$variation_2 = new \WC_Product_Variation();
$variation_2->set_id( 1002 );
$variation_2->set_attributes(
array(
'variation-attribute-1' => 'term_60',
)
);
$variation_2->set_stock_status( 'outofstock' );
$product->set_children( array( 1001, 1002 ) );
$products[1000] = $product;
$products[1001] = $variation_1;
$products[1002] = $variation_2;
$this->sut->update_data_for_product( $product );
$expected = array(
// Main product: one entry for each of the regular attribute values,
// excluding custom product attributes.
array(
'product_id' => '1000',
'product_or_parent_id' => '1000',
'taxonomy' => 'non-variation-attribute',
'term_id' => '10',
'is_variation_attribute' => '0',
'in_stock' => '1',
),
array(
'product_id' => '1000',
'product_or_parent_id' => '1000',
'taxonomy' => 'non-variation-attribute',
'term_id' => '20',
'is_variation_attribute' => '0',
'in_stock' => '1',
),
array(
'product_id' => '1000',
'product_or_parent_id' => '1000',
'taxonomy' => 'non-variation-attribute',
'term_id' => '30',
'is_variation_attribute' => '0',
'in_stock' => '1',
),
// Variation 1: one entry for each of the defined variation attributes.
array(
'product_id' => '1001',
'product_or_parent_id' => '1000',
'taxonomy' => 'variation-attribute-1',
'term_id' => '50',
'is_variation_attribute' => '1',
'in_stock' => '1',
),
array(
'product_id' => '1001',
'product_or_parent_id' => '1000',
'taxonomy' => 'variation-attribute-2',
'term_id' => '90',
'is_variation_attribute' => '1',
'in_stock' => '1',
),
// Variation 2: one entry for the defined value for variation-attribute-1,
// then one for each of the possible values of variation-attribute-2
// (the values defined in the parent product).
array(
'product_id' => '1002',
'product_or_parent_id' => '1000',
'taxonomy' => 'variation-attribute-1',
'term_id' => '60',
'is_variation_attribute' => '1',
'in_stock' => '0',
),
array(
'product_id' => '1002',
'product_or_parent_id' => '1000',
'taxonomy' => 'variation-attribute-2',
'term_id' => '90',
'is_variation_attribute' => '1',
'in_stock' => '0',
),
array(
'product_id' => '1002',
'product_or_parent_id' => '1000',
'taxonomy' => 'variation-attribute-2',
'term_id' => '100',
'is_variation_attribute' => '1',
'in_stock' => '0',
),
array(
'product_id' => '1002',
'product_or_parent_id' => '1000',
'taxonomy' => 'variation-attribute-2',
'term_id' => '110',
'is_variation_attribute' => '1',
'in_stock' => '0',
),
);
$actual = $this->get_lookup_table_data();
$this->assertEquals( sort( $expected ), sort( $actual ) );
}
/**
* Set the product attributes from an array with this format:
*
* [
* 'taxonomy_or_custom_attribute_name' =>
* [
* 'id' => attribute id (0 for custom product attribute),
* 'options' => [term_id, term_id...] (for custom product attributes: ['term', 'term'...]
* 'variation' => 1|0 (optional, default 0)
* ], ...
* ]
*
* @param WC_Product $product The product to set the attributes.
* @param array $attributes_data The attributes to set.
*/
private function set_product_attributes( $product, $attributes_data ) {
$attributes = array();
foreach ( $attributes_data as $taxonomy => $attribute_data ) {
$attribute = new \WC_Product_Attribute();
$attribute->set_id( $attribute_data['id'] );
$attribute->set_name( $taxonomy );
$attribute->set_options( $attribute_data['options'] );
$attribute->set_variation( ArrayUtil::get_value_or_default( $attribute_data, 'variation', false ) );
$attributes[] = $attribute;
}
$product->set_attributes( $attributes );
}
/**
* Get all the data in the lookup table as an array of associative arrays.
*
* @return array All the rows in the lookup table as an array of associative arrays.
*/
private function get_lookup_table_data() {
global $wpdb;
$result = $wpdb->get_results( 'select * from ' . $wpdb->prefix . 'wc_product_attributes_lookup', ARRAY_A );
foreach ( $result as $row ) {
foreach ( $row as $column_name => $value ) {
if ( 'taxonomy' !== $column_name ) {
$row[ $column_name ] = (int) $value;
}
}
}
return $result;
}
}

View File

@ -8,6 +8,7 @@ use Automattic\WooCommerce\Utilities\ArrayUtil;
* A collection of tests for the array utility class.
*/
class ArrayUtilTest extends \WC_Unit_Test_Case {
/**
* @testdox `get_nested_value` should return null if the requested key doesn't exist and no default value is supplied.
*
@ -63,4 +64,74 @@ class ArrayUtilTest extends \WC_Unit_Test_Case {
$this->assertEquals( 'buzz', $actual );
}
/**
* @testdox `is_truthy` returns false when the key does not exist in the array.
*/
public function test_is_truthy_returns_false_if_key_does_not_exist() {
$array = array( 'foo' => 'bar' );
$this->assertFalse( ArrayUtil::is_truthy( $array, 'fizz' ) );
}
/**
* @testdox `is_truthy` returns false for values that evaluate to false.
*
* @testWith [0]
* ["0"]
* [false]
* [""]
* [null]
* [[]]
*
* @param mixed $value Value to test.
*/
public function test_is_truthy_returns_false_if_value_evaluates_to_false( $value ) {
$array = array( 'foo' => $value );
$this->assertFalse( ArrayUtil::is_truthy( $array, 'foo' ) );
}
/**
* @testdox `is_truthy` returns true for values that evaluate to true.
*
* @testWith [1]
* ["foo"]
* [true]
* [[1]]
*
* @param mixed $value Value to test.
*/
public function test_is_truthy_returns_false_if_value_evaluates_to_true( $value ) {
$array = array( 'foo' => $value );
$this->assertTrue( ArrayUtil::is_truthy( $array, 'foo' ) );
}
/**
* @testdox `get_value_or_default` returns the correct value for an existing key.
*/
public function test_get_value_or_default_returns_value_if_key_exists() {
$array = array( 'foo' => 'bar' );
$this->assertEquals( 'bar', ArrayUtil::get_value_or_default( $array, 'foo' ) );
}
/**
* @testdox `get_value_or_default` returns null if the key does not exist and no default value is supplied.
*/
public function test_get_value_or_default_returns_null_if_key_not_exists_and_no_default_supplied() {
$array = array( 'foo' => 'bar' );
$this->assertNull( ArrayUtil::get_value_or_default( $array, 'fizz' ) );
}
/**
* @testdox `get_value_or_default` returns the supplied default value if the key does not exist.
*/
public function test_get_value_or_default_returns_supplied_default_value_if_key_not_exists() {
$array = array( 'foo' => 'bar' );
$this->assertEquals( 'buzz', ArrayUtil::get_value_or_default( $array, 'fizz', 'buzz' ) );
}
}