Optimize the regeneration of the product attributes lookup table (#47700)

Fixes  #46699 

* Add the ProductAttributesLookup\CLIRunner class

* Fix the restoring of the lookup table usage option

* Fix the restoring of the lookup table usage option

* Improve the progress bar of the regeneration command

* Add changelog file

* Fix existing unit tests

* Add linter exception for TODO comment

* Fix example in CLI runner

* Optimize data insertion/update in the product attributes lookup table

The optimized method uses direct database access to the database tables
required to gather all the required data (posts, terms, teaxonomies)
instead of the standard WordPress and WooCommerce functions.
This is done only if:

1. The curent data store for products is the posts table, AND
2. The woocommerce_attribute_lookup_optimized_updates option is 'yes'

Otherwise, the old existing code is used as a fallback instead.

* Adjust the product attributes lookup table CLI tools.

The "regenerate" and "regenerate_for_product" commands will now
use the optimized update method by default if the data store
for products is the posts table (irrespective of the value of the
woocommerce_attribute_lookup_optimized_updates option).
The --disable-db-optimization argument can be appended to
use the old method instead.

* Adjust unit tests for the product attributes lookup table data store.

Now both the optimized data insert/update method and the old
standard functions based method are tested.

* Change the default step size for product attributes lookup table regeneration

The previous value of 10 was too conservative and slowed down the
regeneration unnecessarily (each step involves a call to
wc_get_products, which is quite slow). The new value of 100 is not
too high as to produce bottlenecks, but provides a significant
performance gain.

* Add a missing exception variable

* Add changelog file

* Replace sneaky spaces with tabs

* Make the linter happy

* Add extra code comment for clarity

* Display error messages after CLI commands if table updates fail.

* Use lookup table name variable in SQL query

Co-authored-by: Corey McKrill <916023+coreymckrill@users.noreply.github.com>

* Better description for the newly introduced setting

Co-authored-by: Corey McKrill <916023+coreymckrill@users.noreply.github.com>

* Fix typo in method name and change |= operator to ||

* Fix linting issue and variable initialization

---------

Co-authored-by: Corey McKrill <916023+coreymckrill@users.noreply.github.com>
This commit is contained in:
Néstor Soriano 2024-06-14 22:42:58 +02:00 committed by GitHub
parent 49c76bd080
commit 1e92b0efaf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 991 additions and 391 deletions

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Optimize the regeneration of the product attributes lookup table

View File

@ -601,7 +601,7 @@ class WC_Product_Variable extends WC_Product {
*/
/**
* Sync a variable product with it's children. These sync functions sync
* Sync a variable product with its children. These sync functions sync
* upwards (from child to parent) when the variation is saved.
*
* @param WC_Product|int $product Product object or ID for which you wish to sync.

View File

@ -134,9 +134,12 @@ class CLIRunner {
* <product-id>
* : The id of the product for which the data will be regenerated.
*
* [--disable-db-optimization]
* : Don't use optimized database access even if products are stored as custom post types.
*
* ## EXAMPLES
*
* wp wc palt regenerate_for_product 34
* wp wc palt regenerate_for_product 34 --disable-db-optimization
*
* @param array $args Positional arguments passed to the command.
* @param array $assoc_args Associative arguments (options) passed to the command.
@ -154,10 +157,28 @@ class CLIRunner {
private function regenerate_for_product_core( array $args = array(), array $assoc_args = array() ) {
$product_id = current( $args );
$this->data_regenerator->check_can_do_lookup_table_regeneration( $product_id );
$use_db_optimization = ! array_key_exists( 'disable-db-optimization', $assoc_args );
$this->check_can_use_db_optimization( $use_db_optimization );
$start_time = microtime( true );
$this->lookup_data_store->create_data_for_product( $product_id );
$total_time = microtime( true ) - $start_time;
WP_CLI::success( sprintf( 'Attributes lookup data for product %d regenerated in %f seconds.', $product_id, $total_time ) );
$this->lookup_data_store->create_data_for_product( $product_id, $use_db_optimization );
if ( $this->lookup_data_store->get_last_create_operation_failed() ) {
$this->error( "Lookup data regeneration failed.\nSee the WooCommerce logs (source is %9palt-updates%n) for details." );
} else {
$total_time = microtime( true ) - $start_time;
WP_CLI::success( sprintf( 'Attributes lookup data for product %d regenerated in %f seconds.', $product_id, $total_time ) );
}
}
/**
* If database access optimization is requested but can't be used, show a warning.
*
* @param bool $use_db_optimization True if database access optimization is requested.
*/
private function check_can_use_db_optimization( bool $use_db_optimization ) {
if ( $use_db_optimization && ! $this->lookup_data_store->can_use_optimized_db_access() ) {
$this->warning( "Optimized database access can't be used (products aren't stored as custom post types)." );
}
}
/**
@ -338,6 +359,9 @@ class CLIRunner {
* [--from-scratch]
* : Start table regeneration from scratch even if a regeneration is already in progress.
*
* [--disable-db-optimization]
* : Don't use optimized database access even if products are stored as custom post types.
*
* [--batch-size=<size>]
* : How many products to process in each iteration of the loop.
* ---
@ -407,20 +431,28 @@ class CLIRunner {
$this->data_regenerator->cancel_regeneration_scheduled_action();
$use_db_optimization = ! array_key_exists( 'disable-db-optimization', $assoc_args );
$this->check_can_use_db_optimization( $use_db_optimization );
$progress = WP_CLI\Utils\make_progress_bar( '', $products_count );
$this->log( "Regenerating %W{$table_name}%n..." );
if ( $processed_count > 0 ) {
$progress->tick( $processed_count );
}
$progress->tick( $processed_count );
while ( $this->data_regenerator->do_regeneration_step( $batch_size ) ) {
$regeneration_step_failed = false;
while ( $this->data_regenerator->do_regeneration_step( $batch_size, $use_db_optimization ) ) {
$progress->tick( $batch_size );
$regeneration_step_failed = $regeneration_step_failed || $this->data_regenerator->get_last_regeneration_step_failed();
}
$this->data_regenerator->finalize_regeneration( $was_enabled );
$time = $progress->formatTime( $progress->elapsed() );
$progress->finish();
$this->log( "%GSuccess:%n Table %W{$table_name}%n regenerated in {$time}." );
if ( $regeneration_step_failed ) {
$this->warning( "Lookup data regeneration failed for at least one product.\nSee the WooCommerce logs (source is %9palt-updates%n) for details.\n" );
$this->log( "Table %W{$table_name}%n regenerated in {$time}." );
} else {
$this->log( "%GSuccess:%n Table %W{$table_name}%n regenerated in {$time}." );
}
$info = $this->get_lookup_table_info();
$this->log( "The table contains now %C{$info['total_rows']}%n rows corresponding to %G{$info['products_count']}%n products." );

View File

@ -32,7 +32,7 @@ class DataRegenerator {
use AccessiblePrivateMethods;
public const PRODUCTS_PER_GENERATION_STEP = 10;
public const PRODUCTS_PER_GENERATION_STEP = 100;
/**
* The data store to use.
@ -48,6 +48,13 @@ class DataRegenerator {
*/
private $lookup_table_name;
/**
* Flag indicating if the last regeneration step failed.
*
* @var bool
*/
private $last_regeneration_step_failed;
/**
* DataRegenerator constructor.
*/
@ -71,6 +78,15 @@ class DataRegenerator {
$this->data_store = $data_store;
}
/**
* Check if the last regeneration step failed.
*
* @return bool True if the last regeneration step failed.
*/
public function get_last_regeneration_step_failed() {
return $this->last_regeneration_step_failed;
}
/**
* Initialize the regeneration procedure:
* deletes the lookup table and related options if they exist,
@ -119,10 +135,19 @@ class DataRegenerator {
$this->data_store->unset_regeneration_aborted_flag();
if ( $truncate_table && $this->data_store->check_lookup_table_exists() ) {
$wpdb->query( "TRUNCATE TABLE {$this->lookup_table_name}" ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$this->truncate_lookup_table();
}
}
/**
* Delete all the data from the lookup table.
*/
public function truncate_lookup_table() {
global $wpdb;
$wpdb->query( "TRUNCATE TABLE {$this->lookup_table_name}" ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Create the lookup table and initialize the options that will be temporarily used
* while the regeneration is in progress.
@ -179,7 +204,7 @@ class DataRegenerator {
return;
}
$result = $this->do_regeneration_step();
$result = $this->do_regeneration_step( null, $this->data_store->optimized_data_access_is_enabled() );
if ( $result ) {
$this->enqueue_regeneration_step_run();
} else {
@ -205,16 +230,17 @@ class DataRegenerator {
* the appropriate entries for them in the lookup table.
*
* @param int|null $step_size How many products to process, by default PRODUCTS_PER_GENERATION_STEP will be used.
* @param bool $use_optimized_db_access Use direct database access for data retrieval if possible.
* @return bool True if more steps need to be run, false otherwise.
*/
public function do_regeneration_step( ?int $step_size = self::PRODUCTS_PER_GENERATION_STEP ) {
public function do_regeneration_step( ?int $step_size = null, bool $use_optimized_db_access = false ) {
/**
* Filter to alter the count of products that will be processed in each step of the product attributes lookup table regeneration process.
*
* @since 6.3
* @param int $count Default processing step size.
*/
$products_per_generation_step = apply_filters( 'woocommerce_attribute_lookup_regeneration_step_size', $step_size );
$products_per_generation_step = apply_filters( 'woocommerce_attribute_lookup_regeneration_step_size', $step_size ?? self::PRODUCTS_PER_GENERATION_STEP );
$products_already_processed = get_option( 'woocommerce_attribute_lookup_processed_count', 0 );
@ -234,8 +260,10 @@ class DataRegenerator {
return false;
}
$this->last_regeneration_step_failed = false;
foreach ( $product_ids as $id ) {
$this->data_store->create_data_for_product( $id );
$this->data_store->create_data_for_product( $id, $use_optimized_db_access );
$this->last_regeneration_step_failed = $this->last_regeneration_step_failed || $this->data_store->get_last_create_operation_failed();
}
$products_already_processed += count( $product_ids );
@ -347,7 +375,7 @@ class DataRegenerator {
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 );
$this->data_store->create_data_for_product( $product_id, $this->data_store->optimized_data_access_is_enabled() );
} else {
$this->initiate_regeneration();
}

View File

@ -35,12 +35,29 @@ class LookupDataStore {
private $lookup_table_name;
/**
* LookupDataStore constructor. Makes the feature hidden by default.
* True if the optimized database access setting is enabled AND products are stored as custom post types.
*
* @var bool
*/
private bool $optimized_db_access_is_enabled;
/**
* Flag indicating if the last lookup table creation operation failed.
*
* @var bool
*/
private bool $last_create_operation_failed = false;
/**
* LookupDataStore constructor.
*/
public function __construct() {
global $wpdb;
$this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup';
$this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup';
$this->optimized_db_access_is_enabled =
$this->can_use_optimized_db_access() &&
'yes' === get_option( 'woocommerce_attribute_lookup_optimized_updates' );
$this->init_hooks();
}
@ -55,6 +72,19 @@ class LookupDataStore {
self::add_filter( 'woocommerce_get_settings_products', array( $this, 'add_product_attributes_lookup_table_settings' ), 100, 2 );
}
/**
* Check if optimized database access can be used when creating lookup table entries.
*
* @return bool True if optimized database access can be used.
*/
public function can_use_optimized_db_access() {
try {
return is_a( \WC_Data_Store::load( 'product' )->get_current_class_name(), 'WC_Product_Data_Store_CPT', true );
} catch ( \Exception $ex ) {
return false;
}
}
/**
* Check if the lookup table exists in the database.
*
@ -78,6 +108,15 @@ class LookupDataStore {
return $this->lookup_table_name;
}
/**
* Check if the last lookup data creation operation failed.
*
* @return bool True if the last lookup data creation operation failed.
*/
public function get_last_create_operation_failed() {
return $this->last_create_operation_failed;
}
/**
* 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)
@ -96,7 +135,7 @@ class LookupDataStore {
}
$action = $this->get_update_action( $changeset );
if ( $action !== self::ACTION_NONE ) {
if ( self::ACTION_NONE !== $action ) {
$this->maybe_schedule_update( $product->get_id(), $action );
}
}
@ -158,7 +197,11 @@ class LookupDataStore {
switch ( $action ) {
case self::ACTION_INSERT:
$this->delete_data_for( $product_id );
$this->create_data_for( $product );
if ( $this->optimized_db_access_is_enabled ) {
$this->create_data_for_product_cpt( $product_id );
} else {
$this->create_data_for( $product );
}
break;
case self::ACTION_UPDATE_STOCK:
$this->update_stock_status_for( $product );
@ -192,7 +235,7 @@ class LookupDataStore {
if ( in_array( 'catalog_visibility', $keys, true ) ) {
$new_visibility = $changeset['catalog_visibility'];
if ( $new_visibility === 'visible' || $new_visibility === 'catalog' ) {
if ( 'visible' === $new_visibility || 'catalog' === $new_visibility ) {
return self::ACTION_INSERT;
} else {
return self::ACTION_DELETE;
@ -258,19 +301,20 @@ class LookupDataStore {
* 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.
* @param bool $use_optimized_db_access Use direct database access for data retrieval if possible.
*/
public function create_data_for_product( $product ) {
if ( ! is_a( $product, \WC_Product::class ) ) {
$product = WC()->call_function( 'wc_get_product', $product );
}
public function create_data_for_product( $product, $use_optimized_db_access = false ) {
if ( $use_optimized_db_access ) {
$product_id = intval( ( $product instanceof \WC_Product ) ? $product->get_id() : $product );
$this->create_data_for_product_cpt( $product_id );
} else {
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 );
}
$this->delete_data_for( $product->get_id() );
$this->create_data_for( $product );
}
/**
@ -279,12 +323,28 @@ class LookupDataStore {
* @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 );
$this->last_create_operation_failed = false;
try {
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 );
}
} catch ( \Exception $e ) {
$product_id = $product->get_id();
WC()->call_function( 'wc_get_logger' )->error(
"Lookup data creation (not optimized) failed for product $product_id: " . $e->getMessage(),
array(
'source' => 'palt-updates',
'exception' => $e,
'product_id' => $product_id,
)
);
$this->last_create_operation_failed = true;
}
}
@ -336,13 +396,13 @@ class LookupDataStore {
$product_attributes_data = $this->get_attribute_taxonomies( $product );
$variation_attributes_data = array_filter(
$product_attributes_data,
function( $item ) {
function ( $item ) {
return $item['used_for_variations'];
}
);
$non_variation_attributes_data = array_filter(
$product_attributes_data,
function( $item ) {
function ( $item ) {
return ! $item['used_for_variations'];
}
);
@ -378,7 +438,7 @@ class LookupDataStore {
$product_attributes_data = $this->get_attribute_taxonomies( $main_product );
$variation_attributes_data = array_filter(
$product_attributes_data,
function( $item ) {
function ( $item ) {
return $item['used_for_variations'];
}
);
@ -471,7 +531,7 @@ class LookupDataStore {
private function get_variations_of( \WC_Product_Variable $product ) {
$variation_ids = $product->get_children();
return array_map(
function( $id ) {
function ( $id ) {
return WC()->call_function( 'wc_get_product', $id );
},
$variation_ids
@ -662,7 +722,7 @@ class LookupDataStore {
* @return array New settings configuration array.
*/
private function add_product_attributes_lookup_table_settings( array $settings, string $section_id ): array {
if ( $section_id === 'advanced' && $this->check_lookup_table_exists() ) {
if ( 'advanced' === $section_id && $this->check_lookup_table_exists() ) {
$title_item = array(
'title' => __( 'Product attributes lookup table', 'woocommerce' ),
'type' => 'title',
@ -703,6 +763,16 @@ class LookupDataStore {
'type' => 'checkbox',
'checkboxgroup' => 'start',
);
$settings[] = array(
'title' => __( 'Optimized updates', 'woocommerce' ),
'desc' => __( 'Uses much more performant queries to update the lookup table, but may not be compatible with some extensions.', 'woocommerce' ),
'desc_tip' => __( 'This setting only works when product data is stored in the posts table.', 'woocommerce' ),
'id' => 'woocommerce_attribute_lookup_optimized_updates',
'default' => 'no',
'type' => 'checkbox',
'checkboxgroup' => 'start',
);
}
$settings[] = array( 'type' => 'sectionend' );
@ -710,4 +780,292 @@ class LookupDataStore {
return $settings;
}
/**
* Check if the optimized database access setting is enabled.
*
* @return bool True if the optimized database access setting is enabled.
*/
public function optimized_data_access_is_enabled() {
return 'yes' === get_option( 'woocommerce_attribute_lookup_optimized_updates' );
}
/**
* Create the lookup table data for a product or variation using optimized database access.
* For variable products entries are created for the main product and for all the variations.
*
* @param int $product_id Product or variation id.
*/
private function create_data_for_product_cpt( int $product_id ) {
$this->last_create_operation_failed = false;
try {
$this->create_data_for_product_cpt_core( $product_id );
} catch ( \Exception $e ) {
$data = array(
'source' => 'palt-updates',
'product_id' => $product_id,
);
if ( $e instanceof \WC_Data_Exception ) {
$data = array_merge( $data, $e->getErrorData() );
} else {
$data['exception'] = $e;
}
WC()->call_function( 'wc_get_logger' )
->error( "Lookup data creation (optimized) failed for product $product_id: " . $e->getMessage(), $data );
$this->last_create_operation_failed = true;
}
}
/**
* Core version of create_data_for_product_cpt (doesn't catch exceptions).
*
* @param int $product_id Product or variation id.
* @return void
* @throws \WC_Data_Exception Wrongly serialized attribute data found, or INSERT statement failed.
*/
private function create_data_for_product_cpt_core( int $product_id ) {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL
$sql = $wpdb->prepare(
"delete from {$this->lookup_table_name} where product_or_parent_id=%d",
$product_id
);
$wpdb->query( $sql );
// phpcs:enable WordPress.DB.PreparedSQL
// * Obtain list of product variations, together with stock statuses; also get the product type.
// For a variation this will return just one entry, with type 'variation'.
// Output: $product_ids_with_stock_status = associative array where 'id' is the key and values are the stock status (1 for "in stock", 0 otherwise).
// $variation_ids = raw list of variation ids.
// $is_variable_product = true or false.
// $is_variation = true or false.
$sql = $wpdb->prepare(
"(select p.ID as id, null parent, m.meta_value as stock_status, t.name as product_type from {$wpdb->posts} p
left join {$wpdb->postmeta} m on p.id=m.post_id and m.meta_key='_stock_status'
left join {$wpdb->term_relationships} tr on tr.object_id=p.id
left join {$wpdb->term_taxonomy} tt on tt.term_taxonomy_id=tr.term_taxonomy_id
left join {$wpdb->terms} t on t.term_id=tt.term_id
where p.post_type = 'product'
and p.post_status in ('publish', 'draft', 'pending', 'private')
and tt.taxonomy='product_type'
and t.name != 'exclude-from-search'
and p.id=%d
limit 1)
union
(select p.ID as id, p.post_parent as parent, m.meta_value as stock_status, 'variation' as product_type from {$wpdb->posts} p
left join {$wpdb->postmeta} m on p.id=m.post_id and m.meta_key='_stock_status'
where p.post_type = 'product_variation'
and p.post_status in ('publish', 'draft', 'pending', 'private')
and (p.ID=%d or p.post_parent=%d));
",
$product_id,
$product_id,
$product_id
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$product_ids_with_stock_status = $wpdb->get_results( $sql, ARRAY_A );
$main_product_row = array_filter( $product_ids_with_stock_status, fn( $item ) => 'variation' !== $item['product_type'] );
$is_variation = empty( $main_product_row );
$main_product_id =
$is_variation ?
current( $product_ids_with_stock_status )['parent'] :
$product_id;
$is_variable_product = ! $is_variation && ( 'variable' === current( $main_product_row )['product_type'] );
$product_ids_with_stock_status = ArrayUtil::group_by_column( $product_ids_with_stock_status, 'id', true );
$variation_ids = $is_variation ? array( $product_id ) : array_keys( array_diff_key( $product_ids_with_stock_status, array( $product_id => null ) ) );
$product_ids_with_stock_status = ArrayUtil::select( $product_ids_with_stock_status, 'stock_status' );
$product_ids_with_stock_status = array_map( fn( $item ) => 'instock' === $item ? 1 : 0, $product_ids_with_stock_status );
// * Obtain the list of attributes used for variations and not.
// Output: two lists of attribute slugs, all starting with 'pa_'.
$sql = $wpdb->prepare(
"select meta_value from {$wpdb->postmeta} where post_id=%d and meta_key=%s",
$main_product_id,
'_product_attributes'
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$temp = $wpdb->get_var( $sql );
if ( is_null( $temp ) ) {
// The product has no attributes, thus there's no attributes lookup data to generate.
return;
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
$temp = unserialize( $temp );
if ( false === $temp ) {
throw new \WC_Data_Exception( 0, 'The product attributes metadata row is not properly serialized' );
}
$temp = array_filter( $temp, fn( $item, $slug ) => StringUtil::starts_with( $slug, 'pa_' ) && '' === $item['value'], ARRAY_FILTER_USE_BOTH );
$attributes_not_for_variations =
$is_variation || $is_variable_product ?
array_keys( array_filter( $temp, fn( $item ) => 0 === $item['is_variation'] ) ) :
array_keys( $temp );
// * Obtain the terms used for each attribute.
// Output: $terms_used_per_attribute =
// [
// 'pa_...' => [
// [
// 'term_id' => <term id>,
// 'attribute' => 'pa_...'
// 'slug' => <term slug>
// ],...
// ],...
// ]
$sql = $wpdb->prepare(
"select tt.term_id, tt.taxonomy as attribute, t.slug from {$wpdb->prefix}term_relationships tr
join {$wpdb->term_taxonomy} tt on tt.term_taxonomy_id = tr.term_taxonomy_id
join {$wpdb->terms} t on t.term_id=tt.term_id
where tr.object_id=%d and taxonomy like %s;",
$main_product_id,
'pa_%'
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$terms_used_per_attribute = $wpdb->get_results( $sql, ARRAY_A );
foreach ( $terms_used_per_attribute as &$term ) {
$term['attribute'] = strtolower( rawurlencode( $term['attribute'] ) );
}
$terms_used_per_attribute = ArrayUtil::group_by_column( $terms_used_per_attribute, 'attribute' );
// * Obtain the actual variations defined (only if variations exist).
// Output: $variations_defined =
// [
// <variation id> => [
// [
// 'variation_id' => <variation id>,
// 'attribute' => 'pa_...'
// 'slug' => <term slug>
// ],...
// ],...
// ]
//
// Note that this does NOT include "any..." attributes!
if ( ! $is_variation && ( ! $is_variable_product || empty( $variation_ids ) ) ) {
$variations_defined = array();
} else {
$sql = $wpdb->prepare(
"select post_id as variation_id, substr(meta_key,11) as attribute, meta_value as slug from {$wpdb->postmeta}
where post_id in (select ID from {$wpdb->posts} where (id=%d or post_parent=%d) and post_type = 'product_variation')
and meta_key like %s
and meta_value != ''",
$product_id,
$product_id,
'attribute_pa_%'
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$variations_defined = $wpdb->get_results( $sql, ARRAY_A );
$variations_defined = ArrayUtil::group_by_column( $variations_defined, 'variation_id' );
}
// Now we'll fill an array with all the data rows to be inserted in the lookup table.
$insert_data = array();
// * Insert data for the main product
if ( ! $is_variation ) {
foreach ( $attributes_not_for_variations as $attribute_name ) {
foreach ( ( $terms_used_per_attribute[ $attribute_name ] ?? array() ) as $attribute_data ) {
$insert_data[] = array( $product_id, $main_product_id, $attribute_name, $attribute_data['term_id'], 0, $product_ids_with_stock_status[ $product_id ] );
}
}
}
// * Insert data for the variations defined
// Remove the non-variation attributes data first.
$terms_used_per_attribute = array_diff_key( $terms_used_per_attribute, array_flip( $attributes_not_for_variations ) );
$used_attributes_per_variation = array();
foreach ( $variations_defined as $variation_id => $variation_data ) {
$used_attributes_per_variation[ $variation_id ] = array();
foreach ( $variation_data as $variation_attribute_data ) {
$attribute_name = $variation_attribute_data['attribute'];
$used_attributes_per_variation[ $variation_id ][] = $attribute_name;
$term_id = current( array_filter( ( $terms_used_per_attribute[ $attribute_name ] ?? array() ), fn( $item ) => $item['slug'] === $variation_attribute_data['slug'] ) )['term_id'] ?? null;
if ( is_null( $term_id ) ) {
continue;
}
$insert_data[] = array( $variation_id, $main_product_id, $attribute_name, $term_id, 1, $product_ids_with_stock_status[ $variation_id ] ?? false );
}
}
// * Insert data for variations that have "any..." attributes and at least one defined attribute
foreach ( $used_attributes_per_variation as $variation_id => $attributes_list ) {
$any_attributes = array_diff_key( $terms_used_per_attribute, array_flip( $attributes_list ) );
foreach ( $any_attributes as $attributes_data ) {
foreach ( $attributes_data as $attribute_data ) {
$insert_data[] = array( $variation_id, $main_product_id, $attribute_data['attribute'], $attribute_data['term_id'], 1, $product_ids_with_stock_status[ $variation_id ] ?? false );
}
}
}
// * Insert data for variations that have all their attributes defined as "any..."
$variations_with_all_any = array_keys( array_diff_key( array_flip( $variation_ids ), $used_attributes_per_variation ) );
foreach ( $variations_with_all_any as $variation_id ) {
foreach ( $terms_used_per_attribute as $attribute_name => $attribute_terms ) {
foreach ( $attribute_terms as $attribute_term ) {
$insert_data[] = array( $variation_id, $main_product_id, $attribute_name, $attribute_term['term_id'], 1, $product_ids_with_stock_status[ $variation_id ] ?? false );
}
}
}
// * We have all the data to insert, let's go and insert it.
$insert_data_chunks = array_chunk( $insert_data, 100 );
foreach ( $insert_data_chunks as $insert_data_chunk ) {
$sql = 'INSERT INTO ' . $this->lookup_table_name . ' (
product_id,
product_or_parent_id,
taxonomy,
term_id,
is_variation_attribute,
in_stock)
VALUES (';
$values_strings = array();
foreach ( $insert_data_chunk as $dataset ) {
$attribute_name = esc_sql( $dataset[2] );
$values_strings[] = "{$dataset[0]},{$dataset[1]},'{$attribute_name}',{$dataset[3]},{$dataset[4]},{$dataset[5]}";
}
$sql .= implode( '),(', $values_strings ) . ')';
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$result = $wpdb->query( $sql );
if ( false === $result ) {
throw new \WC_Data_Exception(
0,
'INSERT statement failed',
0,
array(
'db_error' => esc_html( $wpdb->last_error ),
'db_query' => esc_html( $wpdb->last_query ),
)
);
}
}
}
}

View File

@ -35,32 +35,32 @@ class ArrayUtil {
*
* E.g. for [ 'foo' => [ 'bar' => [ 'fizz' => 'buzz' ] ] ] the value for key 'foo::bar::fizz' would be 'buzz'.
*
* @param array $array The array to get the value from.
* @param array $items The array to get the value from.
* @param string $key The complete key hierarchy, using '::' as separator.
* @param mixed $default The value to return if the key doesn't exist in the array.
* @param mixed $default_value The value to return if the key doesn't exist in the array.
*
* @return mixed The retrieved value, or the supplied default value.
* @throws \Exception $array is not an array.
*/
public static function get_nested_value( array $array, string $key, $default = null ) {
public static function get_nested_value( array $items, string $key, $default_value = null ) {
$key_stack = explode( '::', $key );
$subkey = array_shift( $key_stack );
if ( isset( $array[ $subkey ] ) ) {
$value = $array[ $subkey ];
if ( isset( $items[ $subkey ] ) ) {
$value = $items[ $subkey ];
if ( count( $key_stack ) ) {
foreach ( $key_stack as $subkey ) {
if ( is_array( $value ) && isset( $value[ $subkey ] ) ) {
$value = $value[ $subkey ];
} else {
$value = $default;
$value = $default_value;
break;
}
}
}
} else {
$value = $default;
$value = $default_value;
}
return $value;
@ -69,12 +69,12 @@ class ArrayUtil {
/**
* 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 array $items 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 ];
public static function is_truthy( array $items, string $key ) {
return isset( $items[ $key ] ) && $items[ $key ];
}
/**
@ -87,13 +87,13 @@ class ArrayUtil {
* $array['key'] ?? 'default' => 'default'
* ArrayUtil::get_value_or_default($array, 'key', 'default') => null
*
* @param array $array The array to get the value from.
* @param array $items 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.
* @param null $default_value 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 array_key_exists( $key, $array ) ? $array[ $key ] : $default;
public static function get_value_or_default( array $items, string $key, $default_value = null ) {
return array_key_exists( $key, $items ) ? $items[ $key ] : $default_value;
}
/**
@ -149,19 +149,19 @@ class ArrayUtil {
*/
private static function get_selector_callback( string $selector_name, int $selector_type = self::SELECT_BY_AUTO ): \Closure {
if ( self::SELECT_BY_OBJECT_METHOD === $selector_type ) {
$callback = function( $item ) use ( $selector_name ) {
$callback = function ( $item ) use ( $selector_name ) {
return $item->$selector_name();
};
} elseif ( self::SELECT_BY_OBJECT_PROPERTY === $selector_type ) {
$callback = function( $item ) use ( $selector_name ) {
$callback = function ( $item ) use ( $selector_name ) {
return $item->$selector_name;
};
} elseif ( self::SELECT_BY_ARRAY_KEY === $selector_type ) {
$callback = function( $item ) use ( $selector_name ) {
$callback = function ( $item ) use ( $selector_name ) {
return $item[ $selector_name ];
};
} else {
$callback = function( $item ) use ( $selector_name ) {
$callback = function ( $item ) use ( $selector_name ) {
if ( is_array( $item ) ) {
return $item[ $selector_name ];
} elseif ( method_exists( $item, $selector_name ) ) {
@ -269,14 +269,12 @@ class ArrayUtil {
}
$diff[ $key ] = $value;
}
} else {
// phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison -- Intentional when $strict is false.
if ( ! array_key_exists( $key, $array2 ) || $value != $array2[ $key ] ) {
if ( $compare ) {
return true;
}
$diff[ $key ] = $value;
// phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual -- Intentional when $strict is false.
} elseif ( ! array_key_exists( $key, $array2 ) || $value != $array2[ $key ] ) {
if ( $compare ) {
return true;
}
$diff[ $key ] = $value;
}
}
@ -286,40 +284,66 @@ class ArrayUtil {
/**
* Push a value to an array, but only if the value isn't in the array already.
*
* @param array $array The array.
* @param array $items The array.
* @param mixed $value The value to maybe push.
* @return bool True if the value has been added to the array, false if the value was already in the array.
*/
public static function push_once( array &$array, $value ) : bool {
if ( in_array( $value, $array, true ) ) {
public static function push_once( array &$items, $value ): bool {
if ( in_array( $value, $items, true ) ) {
return false;
}
$array[] = $value;
$items[] = $value;
return true;
}
/**
* Ensure that an associative array has a given key, and if not, set the key to an empty array.
*
* @param array $array The array to check.
* @param array $items The array to check.
* @param string $key The key to check.
* @param bool $throw_if_existing_is_not_array If true, an exception will be thrown if the key already exists in the array but the value is not an array.
* @return bool True if the key has been added to the array, false if not (the key already existed).
* @throws \Exception The key already exists in the array but the value is not an array.
*/
public static function ensure_key_is_array( array &$array, string $key, bool $throw_if_existing_is_not_array = false ): bool {
if ( ! isset( $array[ $key ] ) ) {
$array[ $key ] = array();
public static function ensure_key_is_array( array &$items, string $key, bool $throw_if_existing_is_not_array = false ): bool {
if ( ! isset( $items[ $key ] ) ) {
$items[ $key ] = array();
return true;
}
if ( $throw_if_existing_is_not_array && ! is_array( $array[ $key ] ) ) {
$type = is_object( $array[ $key ] ) ? get_class( $array[ $key ] ) : gettype( $array[ $key ] );
if ( $throw_if_existing_is_not_array && ! is_array( $items[ $key ] ) ) {
$type = is_object( $items[ $key ] ) ? get_class( $items[ $key ] ) : gettype( $items[ $key ] );
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
throw new \Exception( "Array key exists but it's not an array, it's a {$type}" );
}
return false;
}
}
/**
* Given an array of associative arrays, all having a shared key name ("column"), generates a new array in which
* keys are the distinct column values found, and values are arrays with all the matches found
* (or only the last matching array found, if $single_values is true).
* See ArrayUtilTest for examples.
*
* @param array $items The array to process.
* @param string $column The name of the key to group by.
* @param bool $single_values True to only return the last suitable array found for each column value.
* @return array The grouped array.
*/
public static function group_by_column( array $items, string $column, bool $single_values = false ): array {
if ( $single_values ) {
return array_combine( array_column( $items, $column ), array_values( $items ) );
}
$distinct_column_values = array_unique( array_column( $items, $column ), SORT_REGULAR );
$result = array_fill_keys( $distinct_column_values, array() );
foreach ( $items as $value ) {
$result[ $value[ $column ] ][] = $value;
}
return $result;
}
}

View File

@ -51,7 +51,7 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
$this->lookup_data_store = new class() extends LookupDataStore {
public $passed_products = array();
public function create_data_for_product( $product ) {
public function create_data_for_product( $product, $use_optimized_db_access = false ) {
$this->passed_products[] = $product;
}
};
@ -111,16 +111,18 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
* @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() {
// phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.Found
$this->register_legacy_proxy_function_mocks(
array(
'wc_get_products' => function( $args ) {
'wc_get_products' => function ( $args ) {
return array( 100 );
},
'time' => function() {
'time' => function () {
return 1000;
},
)
);
// phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.Found
$this->sut->initiate_regeneration();
@ -149,13 +151,15 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
* @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 ) {
// phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.Found
$this->register_legacy_proxy_function_mocks(
array(
'wc_get_products' => function( $args ) use ( $get_products_result ) {
'wc_get_products' => function ( $args ) use ( $get_products_result ) {
return $get_products_result;
},
)
);
// phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.Found
$this->sut->initiate_regeneration();
@ -173,7 +177,7 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
$this->register_legacy_proxy_function_mocks(
array(
'wc_get_products' => function( $args ) use ( &$requested_products_offsets ) {
'wc_get_products' => function ( $args ) use ( &$requested_products_offsets ) {
if ( 'DESC' === current( $args['orderby'] ) ) {
return array( 100 );
} else {
@ -181,7 +185,7 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
return array( 1, 2, 3 );
}
},
'time' => function() {
'time' => function () {
return 1000;
},
)
@ -221,20 +225,24 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
public function test_regeneration_uses_the_woocommerce_attribute_lookup_regeneration_step_size_filter( bool $set_filter ) {
$requested_step_sizes = array();
$filtered_size = DataRegenerator::PRODUCTS_PER_GENERATION_STEP / 2;
// phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.Found
if ( $set_filter ) {
\add_filter(
'woocommerce_attribute_lookup_regeneration_step_size',
function( $default_filter_size ) {
return 100;
function ( $default_filter_size ) use ( $filtered_size ) {
return $filtered_size;
}
);
}
// phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.Found
$this->register_legacy_proxy_function_mocks(
array(
'wc_get_products' => function( $args ) use ( &$requested_step_sizes ) {
'wc_get_products' => function ( $args ) use ( &$requested_step_sizes, $filtered_size ) {
if ( 'DESC' === current( $args['orderby'] ) ) {
return array( 100 );
return array( $filtered_size );
} else {
$requested_step_sizes[] = $args['limit'];
return array( 1, 2, 3 );
@ -251,7 +259,7 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
remove_all_filters( ' woocommerce_attribute_lookup_regeneration_step_size' );
$expected_limit_value = $set_filter ? 100 : DataRegenerator::PRODUCTS_PER_GENERATION_STEP;
$expected_limit_value = $set_filter ? $filtered_size : DataRegenerator::PRODUCTS_PER_GENERATION_STEP;
$this->assertEquals( array( $expected_limit_value ), $requested_step_sizes );
}
@ -267,7 +275,7 @@ class DataRegeneratorTest extends \WC_Unit_Test_Case {
public function test_initiate_regeneration_finishes_when_no_more_products_available( $product_ids ) {
$this->register_legacy_proxy_function_mocks(
array(
'wc_get_products' => function( $args ) use ( &$requested_products_offsets, $product_ids ) {
'wc_get_products' => function ( $args ) use ( &$requested_products_offsets, $product_ids ) {
if ( 'DESC' === current( $args['orderby'] ) ) {
return array( 100 );
} else {

View File

@ -5,6 +5,7 @@
namespace Automattic\WooCommerce\Tests\Internal\ProductAttributesLookup;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
use Automattic\WooCommerce\Testing\Tools\FakeQueue;
@ -29,12 +30,60 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
*/
private $lookup_table_name;
/**
* Product attributes used for the tests.
*
* @var array
*/
private static $attributes;
/**
* Runs before all the tests.
*/
public static function setUpBeforeClass(): void {
parent::setUpBeforeClass();
for ( $i = 1; $i <= 3; $i++ ) {
$taxonomy_id = wc_create_attribute(
array(
'name' => 'tax' . $i,
'has_archives' => true,
)
);
$taxonomy_name = wc_get_attribute( $taxonomy_id )->slug;
register_taxonomy( $taxonomy_name, array( 'product' ) );
self::$attributes[] = array(
'id' => $taxonomy_id,
'name' => $taxonomy_name,
'term_ids' => array(
wp_create_term( "term_{$i}_1", $taxonomy_name )['term_id'],
wp_create_term( "term_{$i}_2", $taxonomy_name )['term_id'],
wp_create_term( "term_{$i}_3", $taxonomy_name )['term_id'],
),
);
}
}
/**
* Runs after all the tests.
*/
public static function tearDownAfterClass(): void {
parent::tearDownAfterClass();
foreach ( self::$attributes as $attribute ) {
wc_delete_attribute( $attribute['id'] );
unregister_taxonomy( $attribute['name'] );
}
}
/**
* Runs before each test.
*/
public function setUp(): void {
global $wpdb;
parent::setUp();
$this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup';
$this->sut = new LookupDataStore();
@ -45,45 +94,44 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
)
);
// Initiating regeneration with a fake queue will just create the lookup table in the database.
$this->get_instance_of( DataRegenerator::class )->initiate_regeneration();
$this->get_instance_of( DataRegenerator::class )->truncate_lookup_table();
}
/**
* @testdox `create_data_for_product` throws an exception if a variation is passed.
* Runs after each test.
*/
public function test_create_data_for_product_throws_if_variation_is_passed() {
$product = new \WC_Product_Variation();
public function tearDown(): void {
parent::tearDown();
$this->expectException( \Exception::class );
$this->expectExceptionMessage( "LookupDataStore::create_data_for_product can't be called for variations." );
$this->sut->create_data_for_product( $product );
delete_option( 'woocommerce_attribute_lookup_optimized_updates' );
}
/**
* @testdox `create_data_for_product` creates the appropriate entries for simple products, skipping custom product attributes.
*
* @testWith [true]
* [false]
* @testWith [true, true]
* [false, true]
* [true, false]
* [false, false]
*
* @param bool $in_stock 'true' if the product is supposed to be in stock.
* @param bool $use_optimized_db_access 'true' to use optimized db access for the table update.
*/
public function test_create_data_for_simple_product( $in_stock ) {
public function test_create_data_for_simple_product( bool $in_stock, bool $use_optimized_db_access ) {
$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 ),
self::$attributes[0]['name'] => array(
'id' => self::$attributes[1]['id'],
'options' => array( self::$attributes[0]['term_ids'][0], self::$attributes[0]['term_ids'][1] ),
),
'pa_attribute_2' => array(
'id' => 200,
'options' => array( 73, 74 ),
self::$attributes[1]['name'] => array(
'id' => self::$attributes[2]['id'],
'options' => array( self::$attributes[1]['term_ids'][0], self::$attributes[1]['term_ids'][1] ),
),
'pa_custom_attribute' => array(
'pa_custom_attribute' => array(
'id' => 0,
'options' => array( 'foo', 'bar' ),
),
@ -97,39 +145,41 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
$product->set_stock_status( 'outofstock' );
$expected_in_stock = 0;
}
$this->save( $product );
$product_id = $product->get_id();
$this->sut->create_data_for_product( $product );
$this->sut->create_data_for_product( $product, $use_optimized_db_access );
$expected = array(
array(
'product_id' => 10,
'product_or_parent_id' => 10,
'taxonomy' => 'pa_attribute_1',
'term_id' => 51,
'product_id' => $product_id,
'product_or_parent_id' => $product_id,
'taxonomy' => self::$attributes[0]['name'],
'term_id' => self::$attributes[0]['term_ids'][0],
'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,
'product_id' => $product_id,
'product_or_parent_id' => $product_id,
'taxonomy' => self::$attributes[0]['name'],
'term_id' => self::$attributes[0]['term_ids'][1],
'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,
'product_id' => $product_id,
'product_or_parent_id' => $product_id,
'taxonomy' => self::$attributes[1]['name'],
'term_id' => self::$attributes[1]['term_ids'][0],
'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,
'product_id' => $product_id,
'product_or_parent_id' => $product_id,
'taxonomy' => self::$attributes[1]['name'],
'term_id' => self::$attributes[1]['term_ids'][1],
'in_stock' => $expected_in_stock,
'is_variation_attribute' => 0,
),
@ -144,49 +194,16 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
/**
* @testdox `create_data_for_product` creates the appropriate entries for variable products.
*
* @testWith [false]
* [true]
*
* @param bool $use_optimized_db_access 'true' to use optimized db access for the table update.
*/
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 ];
},
)
);
public function test_update_data_for_variable_product( bool $use_optimized_db_access ) {
$non_variation_attribute = self::$attributes[0];
$variation_attribute_1 = self::$attributes[1];
$variation_attribute_2 = self::$attributes[2];
/**
* Create a variable product with:
@ -199,141 +216,145 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
*/
$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 ),
$non_variation_attribute['name'] => array(
'id' => $non_variation_attribute['id'],
'options' => $non_variation_attribute['term_ids'],
),
'pa_custom_attribute' => array(
'pa_custom_attribute' => array(
'id' => 0,
'options' => array( 'foo', 'bar' ),
),
'variation-attribute-1' => array(
'id' => 200,
'options' => array( 50, 60, 70 ),
$variation_attribute_1['name'] => array(
'id' => $variation_attribute_1['id'],
'options' => $variation_attribute_1['term_ids'],
'variation' => true,
),
'variation-attribute-2' => array(
'id' => 300,
'options' => array( 90, 100, 110 ),
$variation_attribute_2['name'] => array(
'id' => $variation_attribute_2['id'],
'options' => $variation_attribute_2['term_ids'],
'variation' => true,
),
)
);
$product->set_stock_status( 'instock' );
$product->save();
$product_id = $product->get_id();
$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_attribute_1['name'] => 'term_2_1',
$variation_attribute_2['name'] => 'term_3_1',
)
);
$variation_1->set_stock_status( 'instock' );
$variation_1->set_parent_id( $product_id );
$variation_1->save();
$variation_1_id = $variation_1->get_id();
$variation_2 = new \WC_Product_Variation();
$variation_2->set_id( 1002 );
$variation_2->set_attributes(
array(
'variation-attribute-1' => 'term_60',
$variation_attribute_1['name'] => 'term_2_2',
)
);
$variation_2->set_stock_status( 'outofstock' );
$variation_2->set_parent_id( $product_id );
$variation_2->save();
$variation_2_id = $variation_2->get_id();
$product->set_children( array( 1001, 1002 ) );
$products[1000] = $product;
$products[1001] = $variation_1;
$products[1002] = $variation_2;
$product->set_children( array( $variation_1_id, $variation_2_id ) );
$this->sut->create_data_for_product( $product );
\WC_Product_Variable::sync( $product );
$this->sut->create_data_for_product( $product, $use_optimized_db_access );
$expected = array(
// Main product: one entry for each of the regular attribute values,
// excluding custom product attributes.
// 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',
'product_id' => $product_id,
'product_or_parent_id' => $product_id,
'taxonomy' => $non_variation_attribute['name'],
'term_id' => $non_variation_attribute['term_ids'][0],
'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',
'product_id' => $product_id,
'product_or_parent_id' => $product_id,
'taxonomy' => $non_variation_attribute['name'],
'term_id' => $non_variation_attribute['term_ids'][1],
'is_variation_attribute' => 0,
'in_stock' => 1,
),
array(
'product_id' => $product_id,
'product_or_parent_id' => $product_id,
'taxonomy' => $non_variation_attribute['name'],
'term_id' => $non_variation_attribute['term_ids'][2],
'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',
'product_id' => $variation_1_id,
'product_or_parent_id' => $product_id,
'taxonomy' => $variation_attribute_1['name'],
'term_id' => $variation_attribute_1['term_ids'][0],
'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',
'product_id' => $variation_1_id,
'product_or_parent_id' => $product_id,
'taxonomy' => $variation_attribute_2['name'],
'term_id' => $variation_attribute_2['term_ids'][0],
'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).
// 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',
'product_id' => $variation_2_id,
'product_or_parent_id' => $product_id,
'taxonomy' => $variation_attribute_1['name'],
'term_id' => $variation_attribute_1['term_ids'][1],
'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',
'product_id' => $variation_2_id,
'product_or_parent_id' => $product_id,
'taxonomy' => $variation_attribute_2['name'],
'term_id' => $variation_attribute_2['term_ids'][0],
'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',
'product_id' => $variation_2_id,
'product_or_parent_id' => $product_id,
'taxonomy' => $variation_attribute_2['name'],
'term_id' => $variation_attribute_2['term_ids'][1],
'is_variation_attribute' => 1,
'in_stock' => 0,
),
array(
'product_id' => $variation_2_id,
'product_or_parent_id' => $product_id,
'taxonomy' => $variation_attribute_2['name'],
'term_id' => $variation_attribute_2['term_ids'][2],
'is_variation_attribute' => 1,
'in_stock' => 0,
),
);
@ -786,7 +807,7 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
* @return array[]
*/
public function data_provider_for_test_on_product_changed_with_direct_updates() {
return array(
$changeset_combinations = array(
array(
null,
'creation',
@ -828,6 +849,14 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
'none',
),
);
$data = array();
foreach ( $changeset_combinations as $combination ) {
$data[] = array( $combination[0], $combination[1], false );
$data[] = array( $combination[0], $combination[1], true );
}
return $data;
}
/**
@ -837,58 +866,74 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
*
* @param array $changeset The changeset to test.
* @param string $expected_action The expected performed action, one of 'none', 'creation', 'update' or 'deletion'.
* @param bool $use_optimized_db_access 'true' to use optimized db access for the table update.
*/
public function test_on_product_changed_for_simple_product_with_direct_updates( $changeset, $expected_action ) {
global $wpdb;
public function test_on_product_changed_for_simple_product_with_direct_updates( $changeset, $expected_action, $use_optimized_db_access ) {
if ( $use_optimized_db_access ) {
update_option( 'woocommerce_attribute_lookup_optimized_updates', 'yes' );
$this->sut = new LookupDataStore();
}
$this->set_direct_update_option( true );
$attribute = self::$attributes[0];
$another_attribute = self::$attributes[1];
$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 ),
$attribute['name'] => array(
'id' => $attribute['id'],
'options' => array( $attribute['term_ids'][0] ),
),
)
);
$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 );
}
},
)
);
$product->save();
$product_id = $product->get_id();
$another_product_id = $product_id + 100;
$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 );
// The product creation will have populated the table, but we want to start clean.
$this->get_instance_of( DataRegenerator::class )->truncate_lookup_table();
$this->insert_lookup_table_data( $another_product_id, $another_product_id, $another_attribute['name'], $another_attribute['term_ids'][0], false, true );
if ( 'creation' !== $expected_action && 'deletion' !== $expected_action ) {
$this->insert_lookup_table_data( $product_id, $product_id, $attribute['name'], $attribute['term_ids'][0], 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 );
$actual = $this->get_lookup_table_data();
$expected = array( array( '1', '1', 'pa_foo', '10', '0', '1' ) );
$expected = array(
array(
'product_id' => $another_product_id,
'product_or_parent_id' => $another_product_id,
'taxonomy' => $another_attribute['name'],
'term_id' => $another_attribute['term_ids'][0],
'is_variation_attribute' => 0,
'in_stock' => 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' );
if ( 'deletion' !== $expected_action ) {
$expected[] = array(
'product_id' => $product_id,
'product_or_parent_id' => $product_id,
'taxonomy' => $attribute['name'],
'term_id' => $attribute['term_ids'][0],
'is_variation_attribute' => 0,
'in_stock' => 'creation' === $expected_action || 'update' === $expected_action ? 1 : 0,
);
}
$this->assertEquals( $expected, $rows );
sort( $expected );
sort( $actual );
$this->assertEquals( $expected, $actual );
}
/**
@ -898,99 +943,106 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
*
* @param array $changeset The changeset to test.
* @param string $expected_action The expected performed action, one of 'none', 'creation', 'update' or 'deletion'.
* @param bool $use_optimized_db_access 'true' to use optimized db access for the table update.
*/
public function test_on_variable_product_changed_for_variable_product_with_direct_updates( $changeset, $expected_action ) {
global $wpdb;
public function test_on_variable_product_changed_for_variable_product_with_direct_updates( $changeset, $expected_action, $use_optimized_db_access ) {
if ( $use_optimized_db_access ) {
update_option( 'woocommerce_attribute_lookup_optimized_updates', 'yes' );
$this->sut = new LookupDataStore();
}
$this->set_direct_update_option( true );
$non_variation_attribute = self::$attributes[0];
$variation_attribute = self::$attributes[1];
$another_attribute = self::$attributes[2];
$product = new \WC_Product_Variable();
$product->set_id( 2 );
$this->set_product_attributes(
$product,
array(
'non-variation-attribute' => array(
'id' => 100,
'options' => array( 10 ),
$non_variation_attribute['name'] => array(
'id' => $non_variation_attribute['id'],
'options' => array( $non_variation_attribute['term_ids'][0] ),
),
'variation-attribute' => array(
'id' => 200,
'options' => array( 20 ),
$variation_attribute['name'] => array(
'id' => $variation_attribute['id'],
'options' => array( $variation_attribute['term_ids'][0] ),
'variation' => true,
),
)
);
$product->set_stock_status( 'instock' );
$product->save();
$product_id = $product->get_id();
$variation = new \WC_Product_Variation();
$variation->set_id( 3 );
$variation->set_attributes(
array(
'variation-attribute' => 'term_20',
$variation_attribute['name'] => 'term_2_1',
)
);
$variation->set_stock_status( 'instock' );
$variation->set_parent_id( 2 );
$variation->set_parent_id( $product_id );
$variation->save();
$variation_id = $variation->get_id();
$product->set_children( array( 3 ) );
$product->set_children( array( $variation_id ) );
\WC_Product_Variable::sync( $product );
$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 );
}
},
)
);
$another_product_id = $variation_id + 100;
$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 );
// The product creation will have populated the table, but we want to start clean.
$this->get_instance_of( DataRegenerator::class )->truncate_lookup_table();
$this->insert_lookup_table_data( $another_product_id, $another_product_id, $another_attribute['name'], $another_attribute['term_ids'][0], false, true );
if ( 'creation' !== $expected_action && 'deletion' !== $expected_action ) {
$this->insert_lookup_table_data( $product_id, $product_id, $non_variation_attribute['name'], $non_variation_attribute['term_ids'][0], false, false );
$this->insert_lookup_table_data( $variation_id, $product_id, $variation_attribute['name'], $variation_attribute['term_ids'][0], 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 );
$actual = $this->get_lookup_table_data();
$expected = array( array( '1', '1', 'pa_foo', '10', '0', '1' ) );
$expected = array(
array(
'product_id' => $another_product_id,
'product_or_parent_id' => $another_product_id,
'taxonomy' => $another_attribute['name'],
'term_id' => $another_attribute['term_ids'][0],
'is_variation_attribute' => 0,
'in_stock' => 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' );
if ( 'deletion' !== $expected_action ) {
$expected[] = array(
'product_id' => $product_id,
'product_or_parent_id' => $product_id,
'taxonomy' => $non_variation_attribute['name'],
'term_id' => $non_variation_attribute['term_ids'][0],
'is_variation_attribute' => 0,
'in_stock' => 'none' === $expected_action ? 0 : 1,
);
$expected[] = array(
'product_id' => $variation_id,
'product_or_parent_id' => $product_id,
'taxonomy' => $variation_attribute['name'],
'term_id' => $variation_attribute['term_ids'][0],
'is_variation_attribute' => 1,
'in_stock' => 'creation' === $expected_action ? 1 : 0,
);
}
$this->assertEquals( $expected, $rows );
sort( $expected );
sort( $actual );
$this->assertEquals( $expected, $actual );
}
/**
@ -1000,92 +1052,95 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
*
* @param array $changeset The changeset to test.
* @param string $expected_action The expected performed action, one of 'none', 'creation', 'update' or 'deletion'.
* @param bool $use_optimized_db_access 'true' to use optimized db access for the table update.
*/
public function test_on_variation_changed_for_variable_product_with_direct_updates( $changeset, $expected_action ) {
global $wpdb;
public function test_on_variation_changed_for_variable_product_with_direct_updates( $changeset, $expected_action, $use_optimized_db_access ) {
if ( $use_optimized_db_access ) {
update_option( 'woocommerce_attribute_lookup_optimized_updates', 'yes' );
$this->sut = new LookupDataStore();
}
$non_variation_attribute = self::$attributes[0];
$variation_attribute = self::$attributes[1];
$another_attribute = self::$attributes[2];
$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 ),
$non_variation_attribute['name'] => array(
'id' => $non_variation_attribute['id'],
'options' => array( $non_variation_attribute['term_ids'][0] ),
),
'variation-attribute' => array(
'id' => 200,
'options' => array( 20 ),
$variation_attribute['name'] => array(
'id' => $variation_attribute['id'],
'options' => array( $variation_attribute['term_ids'][0] ),
'variation' => true,
),
)
);
$product->set_stock_status( 'instock' );
$product->save();
$product_id = $product->get_id();
$variation = new \WC_Product_Variation();
$variation->set_id( 3 );
$variation->set_attributes(
array(
'variation-attribute' => 'term_20',
$variation_attribute['name'] => 'term_2_1',
)
);
$variation->set_stock_status( 'instock' );
$variation->set_parent_id( 2 );
$variation->set_parent_id( $product_id );
$variation->save();
$variation_id = $variation->get_id();
$product->set_children( array( 3 ) );
$product->set_children( array( $variation_id ) );
\WC_Product_Variable::sync( $product );
$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 );
}
},
)
);
$another_product_id = $variation_id + 100;
$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 );
// The product creation will have populated the table, but we want to start clean.
$this->get_instance_of( DataRegenerator::class )->truncate_lookup_table();
$this->insert_lookup_table_data( $another_product_id, $another_product_id, $another_attribute['name'], $another_attribute['term_ids'][0], false, true );
if ( 'creation' !== $expected_action && 'deletion' !== $expected_action ) {
$this->insert_lookup_table_data( $variation_id, $product_id, $variation_attribute['name'], $variation_attribute['term_ids'][0], 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 );
$actual = $this->get_lookup_table_data();
$expected = array( array( '1', '1', 'pa_foo', '10', '0', '1' ) );
$expected = array(
array(
'product_id' => $another_product_id,
'product_or_parent_id' => $another_product_id,
'taxonomy' => $another_attribute['name'],
'term_id' => $another_attribute['term_ids'][0],
'is_variation_attribute' => 0,
'in_stock' => 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' );
if ( 'deletion' !== $expected_action ) {
$expected[] = array(
'product_id' => $variation_id,
'product_or_parent_id' => $product_id,
'taxonomy' => $variation_attribute['name'],
'term_id' => $variation_attribute['term_ids'][0],
'is_variation_attribute' => 1,
'in_stock' => 'none' === $expected_action ? 0 : 1,
);
}
$this->assertEquals( $expected, $rows );
sort( $expected );
sort( $actual );
$this->assertEquals( $expected, $actual );
}
/**
@ -1127,10 +1182,10 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
$result = $wpdb->get_results( 'select * from ' . $wpdb->prefix . 'wc_product_attributes_lookup', ARRAY_A );
foreach ( $result as $row ) {
foreach ( $result as &$row ) {
foreach ( $row as $column_name => $value ) {
if ( 'taxonomy' !== $column_name ) {
$row[ $column_name ] = (int) $value;
$row[ $column_name ] = intval( $value );
}
}
}

View File

@ -360,4 +360,95 @@ class ArrayUtilTest extends \WC_Unit_Test_Case {
);
ArrayUtil::ensure_key_is_array( $array, 'bar', true );
}
/**
* @testdox `group_by_column` works as expected when not returning single values.
*/
public function test_group_by_column_not_returning_single_values() {
$data = array(
array(
'name' => 'foo',
'type' => 'buzzword',
),
array(
'name' => 'bar',
'type' => 'buzzword',
),
array(
'name' => 'Panasonic',
'type' => 'MSX maker',
),
array(
'name' => 'Sony',
'type' => 'MSX maker',
),
);
$expected = array(
'buzzword' => array(
array(
'name' => 'foo',
'type' => 'buzzword',
),
array(
'name' => 'bar',
'type' => 'buzzword',
),
),
'MSX maker' => array(
array(
'name' => 'Panasonic',
'type' => 'MSX maker',
),
array(
'name' => 'Sony',
'type' => 'MSX maker',
),
),
);
$actual = ArrayUtil::group_by_column( $data, 'type', false );
$this->assertEquals( $expected, $actual );
}
/**
* @testdox `group_by_column` works as expected when returning single values.
*/
public function test_group_by_column_returning_single_values() {
$data = array(
array(
'name' => 'foo',
'type' => 'buzzword',
),
array(
'name' => 'bar',
'type' => 'buzzword',
),
array(
'name' => 'Panasonic',
'type' => 'MSX maker',
),
array(
'name' => 'Sony',
'type' => 'MSX maker',
),
);
$expected = array(
'buzzword' =>
array(
'name' => 'bar',
'type' => 'buzzword',
),
'MSX maker' =>
array(
'name' => 'Sony',
'type' => 'MSX maker',
),
);
$actual = ArrayUtil::group_by_column( $data, 'type', true );
$this->assertEquals( $expected, $actual );
}
}