From e4e440c92aaedb197eaa8194b0cac59060a9f32b Mon Sep 17 00:00:00 2001 From: Nestor Soriano Date: Tue, 14 May 2024 13:23:11 +0200 Subject: [PATCH] WIP --- plugins/woocommerce/package.json | 1 - .../ProductAttributesLookup/CLIRunner.php | 15 +- .../DataRegenerator.php | 9 +- .../LookupDataStore.php | 245 +++++++++++++++++- .../woocommerce/src/Utilities/ArrayUtil.php | 16 +- .../DataRegeneratorTest.php | 2 +- .../LookupDataStoreTest.php | 41 ++- plugins/woocommerce/woocommerce.php | 4 + 8 files changed, 309 insertions(+), 24 deletions(-) diff --git a/plugins/woocommerce/package.json b/plugins/woocommerce/package.json index 732d2c826ab1..145f4a88675c 100644 --- a/plugins/woocommerce/package.json +++ b/plugins/woocommerce/package.json @@ -418,7 +418,6 @@ "node_modules/@woocommerce/e2e-core-tests/CHANGELOG.md", "node_modules/@woocommerce/api/dist/", "node_modules/@woocommerce/admin-e2e-tests/build", - "node_modules/@woocommerce/classic-assets/build", "node_modules/@woocommerce/block-library/build", "node_modules/@woocommerce/block-library/blocks.ini", "node_modules/@woocommerce/admin-library/build", diff --git a/plugins/woocommerce/src/Internal/ProductAttributesLookup/CLIRunner.php b/plugins/woocommerce/src/Internal/ProductAttributesLookup/CLIRunner.php index 555468757180..e3721175723d 100644 --- a/plugins/woocommerce/src/Internal/ProductAttributesLookup/CLIRunner.php +++ b/plugins/woocommerce/src/Internal/ProductAttributesLookup/CLIRunner.php @@ -134,9 +134,12 @@ private function disable_core( array $args, array $assoc_args ) { * * : The id of the product for which the data will be regenerated. * + * [--use-data-store] + * : Force the usage of the WooCommerce data store classes for the regeneration, instead of direct database access is possible, even if the posts table is currently used for storing products. + * * ## EXAMPLES * - * wp wc palt regenerate 34 + * wp wc palt regenerate 34 --use-data-store * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. @@ -154,8 +157,9 @@ public function regenerate_for_product( array $args = array(), array $assoc_args 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 ); + $force_use_data_store = array_key_exists( 'use-data-store', $assoc_args ); $start_time = microtime( true ); - $this->lookup_data_store->create_data_for_product( $product_id ); + $this->lookup_data_store->create_data_for_product( $product_id, $force_use_data_store ); $total_time = microtime( true ) - $start_time; WP_CLI::success( sprintf( 'Attributes lookup data for product %d regenerated in %f seconds.', $product_id, $total_time ) ); } @@ -338,6 +342,9 @@ private function initiate_regeneration_core( array $args, array $assoc_args ) { * [--from-scratch] * : Start table regeneration from scratch even if a regeneration is already in progress. * + * [--use-data-store] + * : Force the usage of the WooCommerce data store classes for the regeneration, instead of direct database access is possible, even if the posts table is currently used for storing products. + * * [--batch-size=] * : How many products to process in each iteration of the loop. * --- @@ -401,10 +408,12 @@ private function regenerate_core( array $args = array(), array $assoc_args = arr $this->data_regenerator->cancel_regeneration_scheduled_action(); + $force_use_data_store = array_key_exists( 'use-data-store', $assoc_args ); $progress = WP_CLI\Utils\make_progress_bar( '', $last_product_id - $processed_count ); + $progress->display(); $last_product_processed = 0; $this->log( "Regenerating %W{$table_name}%n..." ); - while ( $this->data_regenerator->do_regeneration_step( $batch_size ) ) { + while ( $this->data_regenerator->do_regeneration_step( $batch_size, $force_use_data_store ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $last_product_in_table = $wpdb->get_var( 'select max(product_or_parent_id) from ' . $table_name ) ?? 0; $progress->tick( $last_product_in_table - $last_product_processed ); diff --git a/plugins/woocommerce/src/Internal/ProductAttributesLookup/DataRegenerator.php b/plugins/woocommerce/src/Internal/ProductAttributesLookup/DataRegenerator.php index 727cde765474..0a23235226df 100644 --- a/plugins/woocommerce/src/Internal/ProductAttributesLookup/DataRegenerator.php +++ b/plugins/woocommerce/src/Internal/ProductAttributesLookup/DataRegenerator.php @@ -205,9 +205,10 @@ private function enqueue_regeneration_step_run() { * 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 $force_use_data_store Force the usage of the WooCommerce data store classes for the regeneration, instead of direct database access is possible, even if the posts table is currently used for storing products. * @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 = self::PRODUCTS_PER_GENERATION_STEP, bool $force_use_data_store = false ) { /** * Filter to alter the count of products that will be processed in each step of the product attributes lookup table regeneration process. * @@ -235,7 +236,7 @@ public function do_regeneration_step( ?int $step_size = self::PRODUCTS_PER_GENER } foreach ( $product_ids as $id ) { - $this->data_store->create_data_for_product( $id ); + $this->data_store->create_data_for_product( $id, $force_use_data_store ); } $products_already_processed += count( $product_ids ); @@ -545,7 +546,9 @@ private function run_woocommerce_installed_callback() { // If the lookup table has data, or if it's empty because there are no products yet, we're good. // Otherwise (lookup table is empty but products exist) we need to initiate a regeneration if one isn't already in progress. if ( $this->data_store->lookup_table_has_data() || ! $this->get_last_existing_product_id() ) { - $this->delete_all_attributes_lookup_data( false ); + $must_enable = get_option( 'woocommerce_attribute_lookup_enabled' ) !== 'no'; + $this->delete_all_attributes_lookup_data( false ); + update_option( 'woocommerce_attribute_lookup_enabled', $must_enable ? 'yes' : 'no' ); } else { $this->initiate_regeneration(); } diff --git a/plugins/woocommerce/src/Internal/ProductAttributesLookup/LookupDataStore.php b/plugins/woocommerce/src/Internal/ProductAttributesLookup/LookupDataStore.php index 75f91bec82c7..3a044af3377d 100644 --- a/plugins/woocommerce/src/Internal/ProductAttributesLookup/LookupDataStore.php +++ b/plugins/woocommerce/src/Internal/ProductAttributesLookup/LookupDataStore.php @@ -34,6 +34,8 @@ class LookupDataStore { */ private $lookup_table_name; + private bool $products_store_is_cpt; + /** * LookupDataStore constructor. Makes the feature hidden by default. */ @@ -41,6 +43,7 @@ public function __construct() { global $wpdb; $this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup'; + $this->products_store_is_cpt = 'WC_Product_Data_Store_CPT' === \WC_Data_Store::load( 'product' )->get_current_class_name(); $this->init_hooks(); } @@ -258,9 +261,38 @@ public function on_product_deleted( $product ) { * This method is intended to be called from the data regenerator. * * @param int|WC_Product $product Product object or id. + * @param bool $force_use_data_store Force the usage of the WooCommerce data store classes for the regeneration, instead of direct database access is possible, even if the posts table is currently used for storing products. * @throws \Exception A variation object is passed. */ - public function create_data_for_product( $product ) { + public function create_data_for_product( $product, $force_use_data_store = false) { + global $wpdb; +//$force_use_data_store=true; + if(!$force_use_data_store && $this->products_store_is_cpt) { + $product_id = intval( ($product instanceof \WC_Product) ? $product->get_id() : $product ); + try { + $result = $this->create_data_for_product_cpt( $product_id ); + } catch (\Exception $e) { + wc_get_logger()->error("Lookup data creation failed for product $product_id: " . $e->getMessage(), ['source' => 'palt regeneration', 'exception' => $e]); + return; + } + + if(1 === $result) { + $is_variation = $wpdb->get_var( + $wpdb->prepare( + "select exists(select id from {$wpdb->posts} where ID=%d and post_type='product_variation')", + $product_id + ) + ); + if ($is_variation) { + throw new \Exception("LookupDataStore::create_data_for_product can't be called for variations."); + } + + wc_get_logger()->error("Lookup data creation failed for product $product_id: No data exists for the product in the database", ['source' => 'palt regeneration']); + } + + return; + } + if ( ! is_a( $product, \WC_Product::class ) ) { $product = WC()->call_function( 'wc_get_product', $product ); } @@ -710,4 +742,215 @@ private function add_product_attributes_lookup_table_settings( array $settings, return $settings; } + + public function create_data_for_product_cpt( int $product_id ) { + global $wpdb; + + $wpdb->query("delete from {$wpdb->prefix}wc_product_attributes_lookup where product_or_parent_id = " . $product_id); + + // * Obtain list of product variations, together with stock statuses; also get the product type. + // 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. + + $sql = $wpdb->prepare( + "(select p.ID as id, 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, m.meta_value as stock_status, null 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.post_parent=%d); + ", $product_id, $product_id ); + + $product_ids_with_stock_status = $wpdb->get_results($sql, ARRAY_A); + $main_product_row = current(array_filter($product_ids_with_stock_status, fn($item) => intval($item['id']) === $product_id)); + if(false === $main_product_row) { + return 1; + } + $is_variable_product = 'variable' === $main_product_row['product_type']; + + $product_ids_with_stock_status = ArrayUtil::group_by_column($product_ids_with_stock_status, 'id', true); + $variation_ids = array_keys(array_diff_key($product_ids_with_stock_status, [$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", + $product_id, + '_product_attributes' + ); + + $temp = $wpdb->get_var($sql); + if( is_null( $temp ) ) { + return 2; + } + + $temp = unserialize($temp); + if(false === $temp) { + return 2; + } + + $temp = array_filter($temp, fn($item, $slug) => StringUtil::starts_with($slug, 'pa_') && '' === $item['value'], ARRAY_FILTER_USE_BOTH); + + $attributes_not_for_variations = + array_keys(array_filter($temp, fn($item) => 0 === $item['is_variation'])); + + if($is_variable_product) { + $attributes_for_variations = + array_diff(array_keys($temp), $attributes_not_for_variations); + } + + // * Obtain the terms used for each attribute. + // Output: $terms_used_per_attribute = + // [ + // 'pa_...' => [ + // [ + // 'term_id' => , + // 'attribute' => 'pa_...' + // 'slug' => + // ],... + // ],... + // ] + + $sql = $wpdb->prepare( + "select tt.term_id, tt.taxonomy as attribute, t.slug from {$wpdb->prefix}term_relationships tr + join wp_term_taxonomy tt on tt.term_taxonomy_id = tr.term_taxonomy_id + join wp_terms t on t.term_id=tt.term_id + where tr.object_id=%d and taxonomy like 'pa_%';", + $product_id + ); + + $terms_used_per_attribute = $wpdb->get_results($sql, ARRAY_A); + foreach($terms_used_per_attribute as &$term) { + $term['attribute'] = strtolower(urlencode($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' => , + // 'attribute' => 'pa_...' + // 'slug' => + // ],... + // ],... + // ] + // + // Note that this does NOT include "any..." attributes! + + if(!$is_variable_product || empty($variation_ids)) { + $variations_defined = []; + } 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 post_parent=%d and post_type = 'product_variation') + and meta_key like 'attribute_pa_%' + and meta_value != ''", + $product_id + ); + $variations_defined = $wpdb->get_results($sql, ARRAY_A); + $variations_defined = ArrayUtil::group_by_column($variations_defined, 'variation_id'); + } + + // We have all the data, let's go! + + $insert_data = []; + + //* Insert data for the main product + + foreach($attributes_not_for_variations as $attribute_name) { + foreach(($terms_used_per_attribute[$attribute_name] ?? []) as $attribute_data) { + $insert_data[] = [$product_id, $product_id, $attribute_name, $attribute_data['term_id'], 0, $product_ids_with_stock_status[$product_id]]; + //$this->insert_lookup_table_data($product_id, $product_id, $attribute_name, $attribute_data['term_id'], false, $product_ids_with_stock_status[$product_id]); + } + } + + /*if(empty($variation_ids)) { + return; + }*/ + + //* Insert data for the variations defined + + $used_attributes_per_variation = []; + foreach($variations_defined as $variation_id => $variation_data) { + $used_attributes_per_variation[$variation_id] = []; + 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] ?? []), fn($item) => $item['slug'] === $variation_attribute_data['slug']))['term_id'] ?? null; + if (is_null($term_id)) { + continue; + } + $insert_data[] = [$variation_id, $product_id, $attribute_name, $term_id, 1, $product_ids_with_stock_status[$variation_id] ?? false]; + //$this->insert_lookup_table_data($variation_id, $product_id, $attribute_name, $term_id, true, $product_ids_with_stock_status[$variation_id] ?? false); + } + } + + //* Insert data for variations that have "any..." attributes and at least one defined attribute + + $terms_used_per_attribute = array_diff_key($terms_used_per_attribute, array_flip($attributes_not_for_variations)); + 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[] = [$variation_id, $product_id, $attribute_data['attribute'], $attribute_data['term_id'], 1, $product_ids_with_stock_status[$variation_id] ?? false]; + //$this->insert_lookup_table_data($variation_id, $product_id, $attribute_data['attribute'], $attribute_data['term_id'], true, $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[] = [$variation_id, $product_id, $attribute_name, $attribute_term['term_id'], 1, $product_ids_with_stock_status[$variation_id] ?? false]; + //$this->insert_lookup_table_data($variation_id, $product_id, $attribute_name, $attribute_term['term_id'], true, $product_ids_with_stock_status[$variation_id] ?? false); + } + } + } + + //* We have all the data to insert, 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 = []; + 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) . ')'; + + $wpdb->query($sql); + } + + return 0; + } } diff --git a/plugins/woocommerce/src/Utilities/ArrayUtil.php b/plugins/woocommerce/src/Utilities/ArrayUtil.php index cf24ddcfa5d3..1f67a5152605 100644 --- a/plugins/woocommerce/src/Utilities/ArrayUtil.php +++ b/plugins/woocommerce/src/Utilities/ArrayUtil.php @@ -321,5 +321,19 @@ public static function ensure_key_is_array( array &$array, string $key, bool $th return false; } -} + public static function group_by_column(array $array, string $column, bool $single_values = false): array { + if($single_values) { + return array_combine(array_column($array, $column), array_values($array) ); + } + + $distinct_column_values = array_unique(array_column($array, $column), SORT_REGULAR); + $result = array_fill_keys($distinct_column_values, array()); + + foreach($array as $value) { + $result[$value[$column]][] = $value; + } + + return $result; + } +} diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/DataRegeneratorTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/DataRegeneratorTest.php index 681f842764c9..d56516184a73 100644 --- a/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/DataRegeneratorTest.php +++ b/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/DataRegeneratorTest.php @@ -51,7 +51,7 @@ public function setUp(): void { $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, $force_use_datastore = false ) { $this->passed_products[] = $product; } }; diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/LookupDataStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/LookupDataStoreTest.php index 129fc8cc14d4..f8aa000917f5 100644 --- a/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/LookupDataStoreTest.php +++ b/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/LookupDataStoreTest.php @@ -60,12 +60,22 @@ public function setUp(): void { * @testdox `create_data_for_product` throws an exception if a variation is passed. */ public function test_create_data_for_product_throws_if_variation_is_passed() { - $product = new \WC_Product_Variation(); + $this->set_direct_update_option( false ); + + $product = new \WC_Product_Variable(); + $product->set_id( 1000 ); + + $variation = new \WC_Product_Variation(); + $variation->set_id( 1001 ); + + $product->set_children( array( 1001 ) ); + $this->save( $product ); + $this->save($variation); $this->expectException( \Exception::class ); $this->expectExceptionMessage( "LookupDataStore::create_data_for_product can't be called for variations." ); - $this->sut->create_data_for_product( $product ); + $this->sut->create_data_for_product( $variation ); } /** @@ -104,6 +114,7 @@ public function test_create_data_for_simple_product( $in_stock ) { $product->set_stock_status( 'outofstock' ); $expected_in_stock = 0; } + $this->save($product); $this->sut->create_data_for_product( $product ); @@ -144,7 +155,9 @@ public function test_create_data_for_simple_product( $in_stock ) { $actual = $this->get_lookup_table_data(); - $this->assertEquals( sort( $expected ), sort( $actual ) ); + sort($expected); + sort($actual); + $this->assertEquals( $expected, $actual ); } /** @@ -305,17 +318,17 @@ public function test_update_data_for_variable_product() { ), // 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', - ), + // 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', diff --git a/plugins/woocommerce/woocommerce.php b/plugins/woocommerce/woocommerce.php index 52fe54f0e1d2..6de80019019f 100644 --- a/plugins/woocommerce/woocommerce.php +++ b/plugins/woocommerce/woocommerce.php @@ -69,3 +69,7 @@ function wc_get_container() { if ( class_exists( \Automattic\Jetpack\Connection\Rest_Authentication::class ) ) { \Automattic\Jetpack\Connection\Rest_Authentication::init(); } + +function create_data_for_product_cpt($product_id) { + wc_get_container()->get(\Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore::class)->create_data_for_product_cpt($product_id); +} \ No newline at end of file