Commit 0b08fc82c48 for woocommerce
commit 0b08fc82c48b0190cc1772cffa6ce8110ba64cd1
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Thu Sep 24 18:52:10 2026 +0300
Revert the purchasable-variations candidate scan from #68272 (#69056)
Revert "[perf] Check likely-purchasable variations first in has_purchasable_variations (#68272)"
This reverts commit 98570fc91ef0eb9d4886e77c259cf5d6bacfe2da.
The candidate-first scan changes evaluation order for every store and
trades extra queries for fewer hydrated variations. It is pulled so it
can be tested more against real catalogs and extensions, and polished
before it ships again.
has_purchasable_variations() goes back to priming every child and
checking them in order. The bulk candidate read and the stored-state
helper on WC_Product_Variable_Data_Store_CPT are removed with their
tests.
The only conflict was in the data-store test class, where #68910
appended test_get_price_hash_respects_legacy_price_hash_option() after
the reverted tests; that test is kept. The original changelog file was
already removed from trunk by the 11.2.0-beta.1 release cleanup, so a
new entry records the revert.
Refs #58284
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/revert-68272-purchasable-variations-candidate-scan b/plugins/woocommerce/changelog/revert-68272-purchasable-variations-candidate-scan
new file mode 100644
index 00000000000..8924f1e906c
--- /dev/null
+++ b/plugins/woocommerce/changelog/revert-68272-purchasable-variations-candidate-scan
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Revert the faster purchasable-variation check for variable products so it can get more testing before it ships again.
diff --git a/plugins/woocommerce/includes/class-wc-product-variable.php b/plugins/woocommerce/includes/class-wc-product-variable.php
index 2f5361dfc48..a812e1c62e4 100644
--- a/plugins/woocommerce/includes/class-wc-product-variable.php
+++ b/plugins/woocommerce/includes/class-wc-product-variable.php
@@ -387,31 +387,9 @@ class WC_Product_Variable extends WC_Product {
return $variations;
}
- /**
- * Number of likely-purchasable variations primed and scanned before the rest of the children.
- *
- * @var int
- */
- private const PURCHASABLE_SCAN_BATCH_SIZE = 50;
-
- /**
- * Request-scoped cache group holding the scan order for a product's children.
- *
- * @var string
- */
- private const PURCHASABLE_SCAN_CACHE_GROUP = 'wc_purchasable_scan_order';
-
/**
* Check if there are variations that can be purchased for the current product.
*
- * The children most likely to be purchasable on stored data (published, not out of stock, priced) are
- * primed and checked first, so a product that has a purchasable variation usually hydrates one instead
- * of all of them. With a bulk read, each batch is primed just before it is scanned, so nothing the scan
- * does not reach is primed. Without one, all children are primed up front and then classified and tested
- * in the same pass, so a product whose first child is purchasable reads one child's stored state rather
- * than every child's. Every variation is still evaluated with is_purchasable() and is_in_stock() before
- * returning false, so filters and overrides that widen purchasability keep working.
- *
* @internal
*
* @since 10.0.0
@@ -425,162 +403,22 @@ class WC_Product_Variable extends WC_Product {
* - The transient breaks backward compatibility. The woocommerce_is_purchasable filter from \WC_Product::is_purchasable is used by
* extensions to control product purchasability based on user role, membership, geolocation, or login status.
*/
- $variation_ids = array_values( array_unique( array_map( 'intval', (array) $this->get_children() ) ) );
- if ( empty( $variation_ids ) ) {
- return false;
- }
-
- $candidate_ids = $this->get_stored_state_candidates( $variation_ids );
-
- return null === $candidate_ids
- ? $this->scan_from_primed_caches( $variation_ids )
- : $this->scan_candidate_batches( $variation_ids, $candidate_ids );
- }
-
- /**
- * Scan children with no bulk read available, reading stored state from the primed caches.
- *
- * Everything is primed up front here, so batching would buy nothing and each child is classified and
- * tested in one pass instead. That keeps an early hit at one stored-state read: materialising the whole
- * partition first would read every child's state to answer a question the first child already answers.
- *
- * @param int[] $variation_ids All children, cast to int, in children order.
- * @return bool
- */
- private function scan_from_primed_caches( array $variation_ids ): bool {
- // Prime caches to reduce future queries.
- _prime_post_caches( $variation_ids );
-
- $deferred_ids = array();
- foreach ( $variation_ids as $variation_id ) {
- if ( ! $this->variation_may_be_purchasable( $variation_id ) ) {
- $deferred_ids[] = $variation_id;
- continue;
- }
-
- if ( $this->variation_is_purchasable( $variation_id ) ) {
- return true;
- }
- }
-
- return $this->any_variation_is_purchasable( $deferred_ids );
- }
-
- /**
- * Scan the candidates a bulk read identified, in batches, then everything it left out.
- *
- * @param int[] $variation_ids All children, cast to int, in children order.
- * @param int[] $candidate_ids Children whose stored state leaves room for a purchase, in children order.
- * @return bool
- */
- private function scan_candidate_batches( array $variation_ids, array $candidate_ids ): bool {
- $deferred_ids = array_keys( array_diff_key( array_flip( $variation_ids ), array_flip( $candidate_ids ) ) );
-
- foreach (
- array(
- array_slice( $candidate_ids, 0, self::PURCHASABLE_SCAN_BATCH_SIZE ),
- array_slice( $candidate_ids, self::PURCHASABLE_SCAN_BATCH_SIZE ),
- $deferred_ids,
- ) as $batch
- ) {
- if ( empty( $batch ) ) {
- continue;
- }
-
+ $has_purchasable_variations = false;
+ $variation_ids = $this->get_children();
+ if ( ! empty( $variation_ids ) ) {
// Prime caches to reduce future queries.
- _prime_post_caches( $batch );
-
- if ( $this->any_variation_is_purchasable( $batch ) ) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * Ask the data store which children could be purchasable, judged on stored data alone.
- *
- * @param int[] $variation_ids All children, cast to int, in children order.
- * @return int[]|null Candidate IDs, or null when no bulk read is available or worthwhile.
- */
- private function get_stored_state_candidates( array $variation_ids ): ?array {
- if ( count( $variation_ids ) <= self::PURCHASABLE_SCAN_BATCH_SIZE || wp_using_ext_object_cache() ) {
- return null;
- }
-
- // method_exists() rather than has_callable(): is_callable() is true for any store that declares __call(), which then throws for this method.
- $data_store = $this->data_store;
- if ( ! $data_store instanceof WC_Data_Store || ! method_exists( $data_store->get_current_class_name(), 'get_purchasable_variation_candidates' ) ) {
- return null;
- }
-
- $cache_key = $this->get_id() . ':' . md5( implode( ',', $variation_ids ) );
- $cached = wp_cache_get( $cache_key, self::PURCHASABLE_SCAN_CACHE_GROUP );
- if ( is_array( $cached ) ) {
- return $cached;
- }
-
- // @phpstan-ignore-next-line method.notFound (Guarded by method_exists() above and called via __call() on the underlying data store instance.)
- $candidate_ids = array_map( 'intval', (array) $data_store->get_purchasable_variation_candidates( $this, $variation_ids ) );
- $candidate_ids = array_values( array_unique( array_intersect( $candidate_ids, $variation_ids ) ) );
-
- wp_cache_set( $cache_key, $candidate_ids, self::PURCHASABLE_SCAN_CACHE_GROUP );
-
- return $candidate_ids;
- }
-
- /**
- * Stored-state pre-check for one variation, read from primed post and meta caches.
- *
- * @param int $variation_id Variation ID.
- * @return bool
- */
- private function variation_may_be_purchasable( int $variation_id ): bool {
- return WC_Product_Variable_Data_Store_CPT::stored_state_allows_purchase(
- $this->stored_variation_value( get_post_status( $variation_id ) ),
- $this->stored_variation_value( get_post_meta( $variation_id, '_stock_status', true ) ),
- $this->stored_variation_value( get_post_meta( $variation_id, '_regular_price', true ) ),
- $this->stored_variation_value( get_post_meta( $variation_id, '_sale_price', true ) )
- );
- }
-
- /**
- * Cast one stored post or meta value to the string the pre-check expects.
- *
- * @param mixed $value Stored value.
- * @return string
- */
- private function stored_variation_value( $value ): string {
- return is_scalar( $value ) ? (string) $value : '';
- }
+ _prime_post_caches( $variation_ids );
- /**
- * Run the full purchasability checks on hydrated variations, stopping at the first hit.
- *
- * @param int[] $variation_ids Variation IDs whose caches are primed.
- * @return bool
- */
- private function any_variation_is_purchasable( array $variation_ids ): bool {
- foreach ( $variation_ids as $variation_id ) {
- if ( $this->variation_is_purchasable( $variation_id ) ) {
- return true;
+ foreach ( $variation_ids as $variation_id ) {
+ $variation = wc_get_product( $variation_id );
+ if ( $variation && $variation->is_purchasable() && $variation->is_in_stock() ) {
+ $has_purchasable_variations = true;
+ break;
+ }
}
}
- return false;
- }
-
- /**
- * Full purchasability check for one variation, on a hydrated product object.
- *
- * @param int $variation_id Variation ID.
- * @return bool
- */
- private function variation_is_purchasable( int $variation_id ): bool {
- $variation = wc_get_product( $variation_id );
-
- return $variation && $variation->is_purchasable() && $variation->is_in_stock();
+ return $has_purchasable_variations;
}
/**
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-variable-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-variable-data-store-cpt.php
index c771eda9880..12269330fe3 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-variable-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-variable-data-store-cpt.php
@@ -831,124 +831,6 @@ class WC_Product_Variable_Data_Store_CPT extends WC_Product_Data_Store_CPT imple
return $has_matches;
}
- /**
- * Decide whether stored variation data leaves room for the variation to be purchasable and in stock.
- *
- * This mirrors the filter-free core of WC_Product_Variation::is_purchasable() && is_in_stock(): published,
- * not out of stock, and a regular or sale price present. It is a pre-check, not a verdict: filters,
- * subclasses and custom data stores can still change the real answer either way. A missing stock status
- * counts as in stock, matching the product object default.
- *
- * Called with `self::` here and by class name from WC_Product_Variable, on purpose. The two callers read
- * the same state from different places (this query, and the primed caches), so they have to reach the
- * same predicate; a subclass that redefined it for one path would silently reorder only the other.
- *
- * @internal
- *
- * @since 11.2.0
- *
- * @param string $status Post status.
- * @param string $stock_status Stored `_stock_status` ('' when absent).
- * @param string $regular_price Stored `_regular_price`.
- * @param string $sale_price Stored `_sale_price`.
- * @return bool
- */
- public static function stored_state_allows_purchase( string $status, string $stock_status, string $regular_price, string $sale_price ): bool {
- if ( ProductStatus::PUBLISH !== $status ) {
- return false;
- }
- if ( '' !== $stock_status && ProductStockStatus::OUT_OF_STOCK === $stock_status ) {
- return false;
- }
- return '' !== $regular_price || '' !== $sale_price;
- }
-
- /**
- * Narrow a list of variation IDs to those that could be purchasable and in stock, judged on stored data only.
- *
- * See stored_state_allows_purchase() for the predicate. Input order is preserved; duplicates and unknown
- * IDs are dropped. Reads wp_posts and wp_postmeta directly, by primary key and post_id index only, in
- * two unchunked IN() lookups sized by the caller's children count (measured linear: 8 ms at 500 IDs,
- * 144 ms at 50,000 against a 16 MB max_allowed_packet).
- *
- * @internal
- *
- * @since 11.2.0
- *
- * @param WC_Product $product Parent variable product, used for log context on a failed read.
- * @param int[] $variation_ids Variation IDs to narrow, typically `WC_Product_Variable::get_children()`.
- * @return int[] Subset of `$variation_ids` that may be purchasable.
- */
- public function get_purchasable_variation_candidates( $product, array $variation_ids ): array {
- global $wpdb;
-
- $variation_ids = array_values( array_unique( array_map( 'intval', $variation_ids ) ) );
- if ( empty( $variation_ids ) ) {
- return array();
- }
-
- $placeholders = implode( ', ', array_fill( 0, count( $variation_ids ), '%d' ) );
-
- $status_query = "SELECT ID, post_status FROM {$wpdb->posts} WHERE ID IN ( {$placeholders} )";
- // ORDER BY meta_id so duplicate rows resolve the way get_post_meta( ..., true ) resolves them.
- $meta_query = "SELECT post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id IN ( {$placeholders} ) AND meta_key IN ( '_stock_status', '_regular_price', '_sale_price' ) ORDER BY meta_id ASC";
-
- // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
- $statuses = $wpdb->get_results( $wpdb->prepare( $status_query, ...$variation_ids ), ARRAY_A );
- // last_error is reset per query, so capture it here or a clean second query would hide a failed first one.
- $error = $wpdb->last_error;
- // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
- $meta = $wpdb->get_results( $wpdb->prepare( $meta_query, ...$variation_ids ), ARRAY_A );
- $error = '' !== $error ? $error : $wpdb->last_error;
-
- if ( '' !== $error ) {
- wc_get_logger()->error(
- 'Failed to read stored state for purchasable variation candidates.',
- array(
- 'source' => 'product-variable-data-store',
- 'product_id' => $product->get_id(),
- 'error' => $error,
- )
- );
- }
-
- $state = array();
- foreach ( (array) $statuses as $row ) {
- $state[ (int) $row['ID'] ] = array(
- 'status' => (string) $row['post_status'],
- '_stock_status' => '',
- '_regular_price' => '',
- '_sale_price' => '',
- );
- }
- $seen = array();
- foreach ( (array) $meta as $row ) {
- $id = (int) $row['post_id'];
- $key = $row['meta_key'];
- if ( ! isset( $state[ $id ] ) || isset( $seen[ $id ][ $key ] ) ) {
- continue;
- }
- $seen[ $id ][ $key ] = true;
- // maybe_unserialize() so a serialized value reads the same here as it does through get_post_meta().
- $value = maybe_unserialize( $row['meta_value'] );
- $state[ $id ][ $key ] = is_scalar( $value ) ? (string) $value : '';
- }
-
- return array_values(
- array_filter(
- $variation_ids,
- function ( $variation_id ) use ( $state ) {
- return isset( $state[ $variation_id ] ) && self::stored_state_allows_purchase(
- $state[ $variation_id ]['status'],
- $state[ $variation_id ]['_stock_status'],
- $state[ $variation_id ]['_regular_price'],
- $state[ $variation_id ]['_sale_price']
- );
- }
- )
- );
- }
-
/**
* Syncs all variation names if the parent name is changed.
*
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php b/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php
index e4e45929659..5ed3c0c4704 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php
@@ -1,8 +1,5 @@
<?php
-use Automattic\WooCommerce\Enums\ProductStatus;
-use Automattic\WooCommerce\Enums\ProductStockStatus;
-
/**
* Tests for WC_Product_Variable.
*/
@@ -552,447 +549,4 @@ class WC_Product_Variable_Test extends \WC_Unit_Test_Case {
return $attachment_id;
}
-
- /**
- * Builds a saved variable product whose variations have the given stored state, in order.
- *
- * Each spec is [ post status, stock status, regular price ].
- *
- * @param array $specs Variation specs.
- * @return WC_Product_Variable Freshly loaded parent product.
- */
- private function create_variable_product_with_variations( array $specs ): WC_Product_Variable {
- $parent = new WC_Product_Variable();
- $parent->set_name( 'Scan order fixture' );
- $parent->set_attributes( array( WC_Helper_Product::create_product_attribute_object( 'size', array_map( 'strval', range( 1, count( $specs ) ) ) ) ) );
- $parent->save();
-
- foreach ( $specs as $index => $spec ) {
- list( $status, $stock_status, $regular_price ) = $spec;
- $variation = new WC_Product_Variation();
- $variation->set_parent_id( $parent->get_id() );
- $variation->set_attributes( array( 'pa_size' => (string) ( $index + 1 ) ) );
- $variation->set_status( $status );
- $variation->set_stock_status( $stock_status );
- $variation->set_regular_price( $regular_price );
- $variation->save();
- }
-
- WC_Product_Variable::sync( $parent->get_id() );
- return wc_get_product( $parent->get_id() );
- }
-
- /**
- * Counts how many distinct variations get their purchasability evaluated during a callback.
- *
- * @param callable $callback Code to run.
- * @return int Number of distinct variation IDs passed to woocommerce_variation_is_purchasable.
- */
- private function count_variations_checked( callable $callback ): int {
- $seen = array();
- $counter = function ( $purchasable, $variation ) use ( &$seen ) {
- $seen[ $variation->get_id() ] = true;
- return $purchasable;
- };
- add_filter( 'woocommerce_variation_is_purchasable', $counter, 1, 2 );
- $callback();
- remove_filter( 'woocommerce_variation_is_purchasable', $counter, 1 );
- return count( $seen );
- }
-
- /**
- * @testdox has_purchasable_variations checks likely-purchasable variations before the rest.
- */
- public function test_has_purchasable_variations_checks_candidates_first(): void {
- $product = $this->create_variable_product_with_variations(
- array(
- array( ProductStatus::PUBLISH, ProductStockStatus::OUT_OF_STOCK, '10' ),
- array( ProductStatus::PUBLISH, ProductStockStatus::IN_STOCK, '' ),
- array( ProductStatus::PRIVATE, ProductStockStatus::IN_STOCK, '10' ),
- array( ProductStatus::PUBLISH, ProductStockStatus::IN_STOCK, '10' ),
- )
- );
-
- $result = null;
- $checked = $this->count_variations_checked(
- function () use ( $product, &$result ) {
- $result = $product->has_purchasable_variations();
- }
- );
-
- $this->assertTrue( $result );
- $this->assertSame( 1, $checked, 'Only the purchasable candidate should have been evaluated.' );
- }
-
- /**
- * @testdox has_purchasable_variations still evaluates non-candidates when every candidate is rejected by a filter.
- */
- public function test_has_purchasable_variations_falls_back_to_non_candidates(): void {
- $product = $this->create_variable_product_with_variations(
- array(
- array( ProductStatus::PUBLISH, ProductStockStatus::IN_STOCK, '10' ),
- array( ProductStatus::PUBLISH, ProductStockStatus::OUT_OF_STOCK, '10' ),
- )
- );
- $children = $product->get_children();
-
- // Reject the stored-state candidate, and let a filter make the out-of-stock one purchasable.
- add_filter(
- 'woocommerce_is_purchasable',
- function ( $purchasable, $candidate ) use ( $children ) {
- return $purchasable && (int) $candidate->get_id() !== (int) $children[0];
- },
- 10,
- 2
- );
- add_filter( 'woocommerce_product_is_in_stock', '__return_true' );
-
- $result = null;
- $checked = $this->count_variations_checked(
- function () use ( $product, &$result ) {
- $result = $product->has_purchasable_variations();
- }
- );
-
- $this->assertTrue( $result, 'A filter that makes a non-candidate purchasable must still be honoured.' );
- $this->assertSame( 2, $checked, 'Both variations should have been evaluated, candidate first.' );
- }
-
- /**
- * @testdox has_purchasable_variations returns false when no variation is purchasable, evaluating every variation.
- */
- public function test_has_purchasable_variations_evaluates_all_when_none_purchasable(): void {
- $product = $this->create_variable_product_with_variations(
- array(
- array( ProductStatus::PUBLISH, ProductStockStatus::OUT_OF_STOCK, '10' ),
- array( ProductStatus::PUBLISH, ProductStockStatus::IN_STOCK, '' ),
- array( ProductStatus::PRIVATE, ProductStockStatus::IN_STOCK, '10' ),
- )
- );
-
- $result = null;
- $checked = $this->count_variations_checked(
- function () use ( $product, &$result ) {
- $result = $product->has_purchasable_variations();
- }
- );
-
- $this->assertFalse( $result );
- $this->assertSame( 3, $checked, 'Every variation is evaluated before returning false.' );
- }
-
- /**
- * @testdox has_purchasable_variations evaluates is_purchasable before is_in_stock for each variation.
- */
- public function test_has_purchasable_variations_keeps_check_order_per_variation(): void {
- $product = $this->create_variable_product_with_variations(
- array(
- array( ProductStatus::PUBLISH, ProductStockStatus::IN_STOCK, '10' ),
- )
- );
-
- $calls = array();
- add_filter(
- 'woocommerce_variation_is_purchasable',
- function ( $value ) use ( &$calls ) {
- $calls[] = 'purchasable';
- return $value;
- }
- );
- add_filter(
- 'woocommerce_product_is_in_stock',
- function ( $value ) use ( &$calls ) {
- $calls[] = 'in_stock';
- return $value;
- }
- );
-
- $this->assertTrue( $product->has_purchasable_variations() );
- $this->assertSame( array( 'purchasable', 'in_stock' ), $calls );
- }
-
- /**
- * @testdox has_purchasable_variations finds a purchasable variation beyond the first batch without evaluating the ones before it.
- */
- public function test_has_purchasable_variations_handles_products_above_the_batch_size(): void {
- $specs = array_fill( 0, 60, array( ProductStatus::PUBLISH, ProductStockStatus::OUT_OF_STOCK, '10' ) );
- $specs[] = array( ProductStatus::PUBLISH, ProductStockStatus::IN_STOCK, '10' );
- $product = $this->create_variable_product_with_variations( $specs );
-
- $result = null;
- $checked = $this->count_variations_checked(
- function () use ( $product, &$result ) {
- $result = $product->has_purchasable_variations();
- }
- );
-
- $this->assertTrue( $result );
- $this->assertSame( 1, $checked );
- }
-
- /**
- * @testdox has_purchasable_variations stops reading stored state once a child answers, with no bulk read.
- */
- public function test_has_purchasable_variations_does_not_pre_check_every_child_for_an_early_hit(): void {
- // At or below the batch size there is no bulk read, so this is the path every small product takes.
- $product = $this->create_variable_product_with_variations( array_fill( 0, 40, array( ProductStatus::PUBLISH, ProductStockStatus::IN_STOCK, '10' ) ) );
-
- $read = array();
- add_filter(
- 'get_post_metadata',
- function ( $value, $object_id, $meta_key ) use ( &$read ) {
- if ( '_stock_status' === $meta_key ) {
- $read[ $object_id ] = true;
- }
- return $value;
- },
- 10,
- 3
- );
-
- wp_cache_flush();
- $product = wc_get_product( $product->get_id() );
-
- $this->assertTrue( $product->has_purchasable_variations() );
- $this->assertCount(
- 1,
- $read,
- 'The first child answers the question, so no other child should have its stored state read.'
- );
- }
-
- /**
- * @testdox has_purchasable_variations reaches a candidate beyond the first batch.
- */
- public function test_has_purchasable_variations_scans_the_second_candidate_batch(): void {
- $product = $this->create_variable_product_with_variations( array_fill( 0, 60, array( ProductStatus::PUBLISH, ProductStockStatus::IN_STOCK, '10' ) ) );
- $target = (int) $product->get_children()[55];
-
- // Every child is a stored-state candidate; only the 56th survives the filter, so the hit sits in the second batch.
- add_filter(
- 'woocommerce_variation_is_purchasable',
- function ( $purchasable, $variation ) use ( $target ) {
- return $purchasable && $variation->get_id() === $target;
- },
- 10,
- 2
- );
-
- $result = null;
- $checked = $this->count_variations_checked(
- function () use ( $product, &$result ) {
- $result = $product->has_purchasable_variations();
- }
- );
-
- $this->assertTrue( $result, 'A candidate beyond the first batch must still be reached.' );
- $this->assertSame( 56, $checked, 'The scan stops at the hit in the second batch.' );
- }
-
- /**
- * @testdox has_purchasable_variations supports a proxy store that rejects the optional candidate method.
- */
- public function test_has_purchasable_variations_supports_magic_store_without_candidate_method(): void {
- $data_store = new class() extends WC_Product_Data_Store_CPT {
- /**
- * Delegate for variable-only methods.
- *
- * @var WC_Product_Variable_Data_Store_CPT
- */
- private $variable_store;
-
- /**
- * Sets up the variable-store delegate.
- */
- public function __construct() {
- $this->variable_store = new WC_Product_Variable_Data_Store_CPT();
- }
-
- /**
- * Proxies variable-only methods and rejects the optional candidate method.
- *
- * @param string $method Method name.
- * @param mixed[] $args Arguments.
- * @return mixed
- * @throws BadMethodCallException When the candidate method is requested.
- */
- public function __call( $method, $args ) {
- if ( 'get_purchasable_variation_candidates' === $method ) {
- throw new BadMethodCallException( 'Candidate lookup is not supported.' );
- }
-
- return $this->variable_store->$method( ...$args );
- }
- };
-
- add_filter(
- 'woocommerce_data_stores',
- static function ( $stores ) use ( $data_store ) {
- $stores['product-variable'] = $data_store;
- return $stores;
- }
- );
-
- $specs = array_fill( 0, 50, array( ProductStatus::PUBLISH, ProductStockStatus::OUT_OF_STOCK, '10' ) );
- $specs[] = array( ProductStatus::PUBLISH, ProductStockStatus::IN_STOCK, '10' );
- $product = $this->create_variable_product_with_variations( $specs );
-
- $this->assertSame( get_class( $data_store ), $product->get_data_store()->get_current_class_name() );
- $this->assertTrue( $product->has_purchasable_variations(), 'The fallback scan must reach the purchasable child without calling the unsupported method.' );
- }
-
- /**
- * @testdox has_purchasable_variations ignores candidate IDs from the data store that are not children of the product.
- */
- public function test_has_purchasable_variations_ignores_foreign_candidate_ids_from_data_store(): void {
- $foreign = WC_Helper_Product::create_simple_product();
-
- $data_store = new class() extends WC_Product_Variable_Data_Store_CPT {
- /**
- * ID of a purchasable product that is not a child of the product under test.
- *
- * @var int
- */
- public $foreign_id = 0;
-
- /**
- * Returns the foreign product plus the first child twice.
- *
- * @param WC_Product_Variable $product Parent product.
- * @param int[] $variation_ids Children being scanned.
- * @return int[]
- */
- public function get_purchasable_variation_candidates( $product, array $variation_ids ): array {
- return array( $this->foreign_id, $variation_ids[0], $variation_ids[0] );
- }
- };
-
- $data_store->foreign_id = $foreign->get_id();
- // Registered before the fixture exists: the product factory caches instances, so a product
- // loaded earlier would keep the stock data store.
- add_filter(
- 'woocommerce_data_stores',
- static function ( $stores ) use ( $data_store ) {
- $stores['product-variable'] = $data_store;
- return $stores;
- }
- );
-
- $specs = array_fill( 0, 61, array( ProductStatus::PUBLISH, ProductStockStatus::OUT_OF_STOCK, '10' ) );
- $product = $this->create_variable_product_with_variations( $specs );
- $child = $product->get_children()[0];
- $this->assertSame( get_class( $data_store ), $product->get_data_store()->get_current_class_name() );
-
- $checked_ids = array();
- $variation_hits = array();
- add_filter(
- 'woocommerce_is_purchasable',
- function ( $purchasable, $checked ) use ( &$checked_ids ) {
- $checked_ids[] = $checked->get_id();
- return $purchasable;
- },
- 1,
- 2
- );
- add_filter(
- 'woocommerce_variation_is_purchasable',
- function ( $purchasable, $variation ) use ( &$variation_hits ) {
- $variation_hits[] = $variation->get_id();
- return $purchasable;
- },
- 1,
- 2
- );
-
- $this->assertFalse( $product->has_purchasable_variations() );
- $this->assertNotContains( $foreign->get_id(), $checked_ids );
- $this->assertSame( 1, count( array_keys( $variation_hits, $child, true ) ) );
- }
-
- /**
- * @testdox has_purchasable_variations respects the woocommerce_get_children filter.
- */
- public function test_has_purchasable_variations_respects_children_filter(): void {
- $product = $this->create_variable_product_with_variations(
- array(
- array( ProductStatus::PUBLISH, ProductStockStatus::IN_STOCK, '10' ),
- array( ProductStatus::PUBLISH, ProductStockStatus::OUT_OF_STOCK, '10' ),
- )
- );
- $children = $product->get_children();
-
- add_filter(
- 'woocommerce_get_children',
- function ( $ids ) use ( $children ) {
- return array_values( array_intersect( $ids, array( $children[1] ) ) );
- }
- );
-
- $this->assertFalse( $product->has_purchasable_variations(), 'Only the filtered child list may be considered.' );
- }
-
- /**
- * @testdox has_purchasable_variations asks the data store for candidates only above the batch size, and once per request.
- */
- public function test_has_purchasable_variations_uses_the_candidate_query_only_when_it_pays(): void {
- $data_store = new class() extends WC_Product_Variable_Data_Store_CPT {
- /**
- * How many times the candidate query was asked for.
- *
- * @var int
- */
- public $calls = 0;
-
- /**
- * Counts the call and defers to the real query.
- *
- * @param WC_Product_Variable $product Parent product.
- * @param int[] $variation_ids Children being scanned.
- * @return int[]
- */
- public function get_purchasable_variation_candidates( $product, array $variation_ids ): array {
- ++$this->calls;
- return parent::get_purchasable_variation_candidates( $product, $variation_ids );
- }
- };
-
- // Registered before the fixtures exist: the product factory caches instances, so a product
- // loaded earlier would keep the stock data store.
- add_filter(
- 'woocommerce_data_stores',
- static function ( $stores ) use ( $data_store ) {
- $stores['product-variable'] = $data_store;
- return $stores;
- }
- );
-
- $spec = array( ProductStatus::PUBLISH, ProductStockStatus::OUT_OF_STOCK, '10' );
- $at_batch = $this->create_variable_product_with_variations( array_fill( 0, 50, $spec ) );
- $above_batch = $this->create_variable_product_with_variations( array_fill( 0, 51, $spec ) );
-
- $at_batch->has_purchasable_variations();
- $this->assertSame( 0, $data_store->calls, 'A product within one batch is scanned from the primed caches alone.' );
-
- $above_batch->has_purchasable_variations();
- $this->assertSame( 1, $data_store->calls, 'A product above the batch size asks the data store for candidates.' );
-
- $above_batch->has_purchasable_variations();
- $this->assertSame( 1, $data_store->calls, 'Repeat calls in the same request reuse the memoised candidate list.' );
-
- // Drop the memo so the next two calls really re-enter the branch.
- wp_cache_flush();
- $previous = wp_using_ext_object_cache( true );
- try {
- $above_batch->has_purchasable_variations();
- } finally {
- // Always restore. Cast to bool because wp_using_ext_object_cache( null ) is a
- // no-op, which would otherwise leak the simulated true state into later tests.
- wp_using_ext_object_cache( (bool) $previous );
- }
- $this->assertSame( 1, $data_store->calls, 'A persistent object cache makes priming cheap, so the candidate query is skipped.' );
-
- wp_cache_flush();
- $above_batch->has_purchasable_variations();
- $this->assertSame( 2, $data_store->calls, 'Without the memo or a persistent object cache, the candidate query runs again.' );
- }
}
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-variable-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-variable-data-store-cpt-test.php
index 284d1e6215d..69e1f94b740 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-variable-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-variable-data-store-cpt-test.php
@@ -1,6 +1,5 @@
<?php declare( strict_types = 1 );
-use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductStockStatus;
/**
@@ -2009,224 +2008,6 @@ class WC_Product_Variable_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$product->delete( true );
}
- /**
- * Creates a variation under the class fixture product with the given stored state.
- *
- * @param string $status Post status.
- * @param string|null $stock_status Value for `_stock_status`, or null to leave the meta absent.
- * @param string $regular_price Regular price ('' for none).
- * @param string $sale_price Sale price ('' for none).
- * @return int Variation ID.
- */
- private function create_stored_variation( string $status, ?string $stock_status, string $regular_price, string $sale_price ): int {
- $variation = new WC_Product_Variation();
- $variation->set_parent_id( self::$product_id );
- $variation->set_status( $status );
- $variation->set_regular_price( $regular_price );
- $variation->set_sale_price( $sale_price );
- $variation->set_stock_status( $stock_status ?? ProductStockStatus::IN_STOCK );
- $variation->save();
- if ( null === $stock_status ) {
- delete_post_meta( $variation->get_id(), '_stock_status' );
- }
- if ( '' === $regular_price && '' !== $sale_price ) {
- // The CRUD layer blanks a sale price without a regular price; write it directly to model legacy or direct-meta data.
- update_post_meta( $variation->get_id(), '_sale_price', $sale_price );
- }
- return $variation->get_id();
- }
-
- /**
- * @testdox stored_state_allows_purchase applies the published, not-out-of-stock, priced rule.
- *
- * @testWith [ "publish", "instock", "10", "", true ]
- * [ "publish", "onbackorder", "10", "", true ]
- * [ "publish", "", "10", "", true ]
- * [ "publish", "instock", "0", "", true ]
- * [ "publish", "instock", "", "5", true ]
- * [ "publish", "outofstock", "10", "", false ]
- * [ "publish", "instock", "", "", false ]
- * [ "private", "instock", "10", "", false ]
- * [ "draft", "instock", "10", "", false ]
- *
- * @param string $status Post status.
- * @param string $stock_status Stock status.
- * @param string $regular_price Regular price.
- * @param string $sale_price Sale price.
- * @param bool $expected Expected result.
- */
- public function test_stored_state_allows_purchase( string $status, string $stock_status, string $regular_price, string $sale_price, bool $expected ): void {
- $this->assertSame( $expected, WC_Product_Variable_Data_Store_CPT::stored_state_allows_purchase( $status, $stock_status, $regular_price, $sale_price ) );
- }
-
- /**
- * @testdox get_purchasable_variation_candidates keeps only published, not-out-of-stock, priced variations, in input order.
- */
- public function test_get_purchasable_variation_candidates_filters_on_stored_state_and_preserves_order(): void {
- $sut = new WC_Product_Variable_Data_Store_CPT();
-
- $out_of_stock = $this->create_stored_variation( ProductStatus::PUBLISH, ProductStockStatus::OUT_OF_STOCK, '10', '' );
- $unpriced = $this->create_stored_variation( ProductStatus::PUBLISH, ProductStockStatus::IN_STOCK, '', '' );
- $private = $this->create_stored_variation( ProductStatus::PRIVATE, ProductStockStatus::IN_STOCK, '10', '' );
- $in_stock = $this->create_stored_variation( ProductStatus::PUBLISH, ProductStockStatus::IN_STOCK, '10', '' );
- $backorder = $this->create_stored_variation( ProductStatus::PUBLISH, ProductStockStatus::ON_BACKORDER, '10', '' );
- $free = $this->create_stored_variation( ProductStatus::PUBLISH, ProductStockStatus::IN_STOCK, '0', '' );
- $sale_only = $this->create_stored_variation( ProductStatus::PUBLISH, ProductStockStatus::IN_STOCK, '', '5' );
- $no_stock_row = $this->create_stored_variation( ProductStatus::PUBLISH, null, '10', '' );
-
- $input = array( $backorder, $out_of_stock, $in_stock, $unpriced, $free, $private, $sale_only, $no_stock_row, 999999999 );
- $result = $sut->get_purchasable_variation_candidates( wc_get_product( self::$product_id ), $input );
-
- $this->assertSame(
- array( $backorder, $in_stock, $free, $sale_only, $no_stock_row ),
- $result,
- 'Candidates must be the stored-state subset in the same order as the input.'
- );
- }
-
- /**
- * @testdox get_purchasable_variation_candidates returns an empty array for empty input without querying.
- */
- public function test_get_purchasable_variation_candidates_returns_empty_for_empty_input(): void {
- global $wpdb;
- $sut = new WC_Product_Variable_Data_Store_CPT();
-
- $product = wc_get_product( self::$product_id );
- $queries_before = $wpdb->num_queries;
- $result = $sut->get_purchasable_variation_candidates( $product, array() );
-
- $this->assertSame( array(), $result );
- $this->assertSame( $queries_before, $wpdb->num_queries, 'Empty input must not hit the database.' );
- }
-
- /**
- * @testdox get_purchasable_variation_candidates deduplicates and casts input IDs.
- */
- public function test_get_purchasable_variation_candidates_deduplicates_and_casts_ids(): void {
- $sut = new WC_Product_Variable_Data_Store_CPT();
- $in_stock = $this->create_stored_variation( ProductStatus::PUBLISH, ProductStockStatus::IN_STOCK, '10', '' );
-
- $result = $sut->get_purchasable_variation_candidates( wc_get_product( self::$product_id ), array( (string) $in_stock, $in_stock, "$in_stock" ) );
-
- $this->assertSame( array( $in_stock ), $result );
- }
-
- /**
- * @testdox SQL and primed-cache paths classify the same stored rows identically, using the first duplicate row.
- *
- * @testWith [ "publish", ["instock"], ["10"], [""], true ]
- * [ "private", ["instock"], ["10"], [""], false ]
- * [ "publish", ["outofstock"], ["10"], [""], false ]
- * [ "publish", ["onbackorder"], ["10"], [""], true ]
- * [ "publish", ["instock"], [""], [""], false ]
- * [ "publish", ["instock"], ["0"], [""], true ]
- * [ "publish", ["instock"], [""], ["5"], true ]
- * [ "publish", [], ["10"], [""], true ]
- * [ "publish", ["instock"], [["10"]], [""], false ]
- * [ "publish", ["instock"], [""], [["5"]], false ]
- * [ "publish", ["instock"], ["", "10"], [""], false ]
- * [ "publish", ["instock"], ["10", ""], [""], true ]
- * [ "publish", ["instock"], [""], ["", "5"], false ]
- * [ "publish", ["instock"], [""], ["5", ""], true ]
- * [ "publish", ["", "outofstock"], ["10"], [""], true ]
- * [ "publish", ["outofstock", ""], ["10"], [""], false ]
- *
- * @param string $status Post status.
- * @param array $stock_rows Stock metadata rows in insertion order.
- * @param array $regular_rows Regular-price metadata rows in insertion order.
- * @param array $sale_rows Sale-price metadata rows in insertion order.
- * @param bool $expected Whether the variation is a stored-state candidate.
- */
- public function test_get_purchasable_variation_candidates_matches_primed_cache( string $status, array $stock_rows, array $regular_rows, array $sale_rows, bool $expected ): void {
- $sut = new WC_Product_Variable_Data_Store_CPT();
- $variation_id = $this->create_stored_variation( $status, ProductStockStatus::IN_STOCK, '', '' );
- $product = new WC_Product_Variable( self::$product_id );
-
- // Bypass CRUD upserts to preserve duplicate rows and malformed or missing metadata.
- foreach (
- array(
- '_stock_status' => $stock_rows,
- '_regular_price' => $regular_rows,
- '_sale_price' => $sale_rows,
- ) as $key => $rows
- ) {
- delete_post_meta( $variation_id, $key );
- foreach ( $rows as $value ) {
- add_post_meta( $variation_id, $key, $value );
- }
- }
-
- $sql_candidates = $sut->get_purchasable_variation_candidates( $product, array( $variation_id ) );
- clean_post_cache( $variation_id );
- _prime_post_caches( array( $variation_id ) );
- $cache_candidate = $this->invokeMethod( $product, 'variation_may_be_purchasable', array( $variation_id ) );
-
- $this->assertSame( $expected ? array( $variation_id ) : array(), $sql_candidates, 'SQL must classify the stored rows using their first value.' );
- $this->assertSame( $expected, $cache_candidate, 'The primed-cache path must agree with the expected SQL classification.' );
- }
-
- /**
- * @testdox get_purchasable_variation_candidates logs a failure from either read and still answers.
- *
- * @testWith [ "posts" ]
- * [ "postmeta" ]
- *
- * @param string $table Table whose read is broken.
- */
- public function test_get_purchasable_variation_candidates_logs_a_failed_read( string $table ): void {
- $sut = new WC_Product_Variable_Data_Store_CPT();
- $variation_id = $this->create_stored_variation( ProductStatus::PUBLISH, ProductStockStatus::IN_STOCK, '10', '' );
-
- $logger = new class() extends WC_Logger {
- /**
- * Captured error messages.
- *
- * @var array
- */
- public $errors = array();
-
- /**
- * Records an error.
- *
- * @param string $message Message.
- * @param array $context Context.
- */
- public function error( $message, $context = array() ) {
- $this->errors[] = array( $message, $context );
- }
- };
-
- $logger_filter = static function () use ( $logger ) {
- return $logger;
- };
-
- // Break only the read under test, so a clean sibling query cannot mask it.
- $query_filter = static function ( $query ) use ( $table ) {
- global $wpdb;
- $name = $wpdb->{$table};
- return str_contains( $query, "FROM {$name} WHERE" ) ? str_replace( $name, $name . '_missing', $query ) : $query;
- };
-
- global $wpdb;
-
- add_filter( 'woocommerce_logging_class', $logger_filter );
- add_filter( 'query', $query_filter );
- $previous_suppress_errors = $wpdb->suppress_errors( true );
-
- try {
- $result = $sut->get_purchasable_variation_candidates( wc_get_product( self::$product_id ), array( $variation_id ) );
- } finally {
- // suppress_errors() is not part of what the test case restores, so a throw here would silence
- // database errors for the rest of the run.
- remove_filter( 'woocommerce_logging_class', $logger_filter );
- remove_filter( 'query', $query_filter );
- $wpdb->suppress_errors( $previous_suppress_errors );
- }
-
- $this->assertCount( 1, $logger->errors, "A failed read of {$table} must be logged once." );
- $this->assertSame( self::$product_id, $logger->errors[0][1]['product_id'] );
- $this->assertSame( array(), $result, 'A failed read defers every variation rather than promoting it.' );
- }
/**
* @testdox get_price_hash respects the woocommerce_use_legacy_get_variations_price_hash option.