Commit 98570fc91ef for woocommerce
commit 98570fc91ef0eb9d4886e77c259cf5d6bacfe2da
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Mon Sep 7 17:32:41 2026 +0300
[perf] Check likely-purchasable variations first in has_purchasable_variations (#68272)
* perf(product): Add stored-state candidate query for variable product children
Add WC_Product_Variable_Data_Store_CPT::get_purchasable_variation_candidates()
which narrows a list of variation IDs to those whose stored post status,
stock status and prices allow them to be purchasable, using two index-only
queries and no product object hydration, plus the static predicate
stored_state_allows_purchase() it is built on.
This is groundwork for scanning likely-purchasable variations first in
WC_Product_Variable::has_purchasable_variations(). It is added to the
concrete CPT data store only, not to the data store interface, so
third-party data store implementations keep loading.
Refs #58284
* perf(product): Scan likely-purchasable variations first in has_purchasable_variations
has_purchasable_variations() primed and hydrated every child variation
until it found one that was purchasable and in stock. On listings and
Store API responses that runs once per variable product, so stores with
many sold-out or unpriced variations paid a linear PHP hydration cost
per product (about 0.1 ms and 13 KB per variation, plus priming all
children up front regardless of where the first hit was).
Children are now primed and checked in batches of 50. Within a batch,
variations that could be purchasable on stored data alone (published,
not out of stock, priced) are checked first and the rest are deferred.
For products with more than 50 children, and only when no persistent
object cache is in use, the CPT data store supplies the candidate list
in two index-only queries so the scan can jump straight to them. If no
candidate passes, the deferred children are scanned exactly as before,
so every variation still goes through is_purchasable() and
is_in_stock() on a real object, in that order, before the method
returns false.
Results are unchanged for every configuration and filter combination
exercised by the differential harness (14 product scenarios x 10 filter
sets x hide-out-of-stock on/off x 3 extension phases, 0 mismatches,
with Memberships, Name Your Price, Catalog Visibility Options, Dynamic
Pricing, B2B and Wholesale active). The only observable
difference is the order in which variations are evaluated, which a
filter keyed on call order could notice.
Refs #58284
* chore(changelog): Add entry for purchasable variations candidate scan
Refs #58284
* fix(product): keep purchasable scan to the product's own children
get_purchasable_scan_order() took the candidate list returned by the
data store at face value and merged it into the scan. The core CPT
implementation only ever returns a subset of the IDs it was given, but
the method is public on a class extensions subclass, so an override
could hand back a stale, foreign or duplicated ID. A foreign purchasable
product in that list would make has_purchasable_variations() answer
true for a variable product that has no purchasable variation.
Intersect the returned IDs with the children being scanned and drop
duplicates before ordering. The regression test registers a data store
that returns a foreign in-stock product plus one child twice, and
checks that the foreign product is never evaluated and the child is
evaluated once. The store is registered before the fixture is created
because the product factory caches instances per request.
Refs #58284
* fix(product): Guard the candidate query on the data store class name
has_purchasable_variations() asked WC_Data_Store::has_callable() whether the
active product data store offers get_purchasable_variation_candidates(). That
method is is_callable( array( $instance, $method ) ), which answers true for
every method name once the store declares __call(), so the guard let the call
through to stores that have no such method.
A store implementing WC_Object_Data_Store_Interface whose __call() throws for
unknown methods then fataled on render: any variable product with more than 50
variations threw straight out of has_purchasable_variations(). Trunk and
products within one batch were unaffected, which is why the existing coverage
missed it.
Test the concrete class instead. WC_Data_Store::get_current_class_name()
returns the real class name in both constructor branches, so this stays true
for the default store and for a CPT subclass, and false for a proxy that only
forwards through __call().
Refs #58284
* fix(product): Ignore non-scalar rows in the purchasable pre-check
The pre-check cast four stored values straight to string. A serialized array
in _regular_price, which WordPress hands back as an array, turned into the
string 'Array' after emitting an "Array to string conversion" notice, and
'Array' reads as a price, so the variation was treated as a candidate.
Trunk is silent on the same row, so this was a new notice on a state that
already exists on real stores.
Read non-scalar values as absent. The variation is then deferred rather than
promoted, and the full is_purchasable() check still decides the answer.
Refs #58284
* fix(product): Resolve duplicate meta rows like get_post_meta does
The candidate query read _stock_status, _regular_price and _sale_price with
no ORDER BY, and kept the first non-empty value it saw per key. get_post_meta(
..., true ) returns the first row by meta_id, empty or not, so the two
disagreed whenever a variation carried duplicate rows for one key.
Reproduced with _regular_price rows '' then '10' on a published, in-stock
variation: get_post_meta() returns '' and the per-variation pre-check defers
the variation, while the query returned it as a candidate. The answer stayed
correct either way, since both paths only choose scan order, but the two
pre-checks are supposed to agree.
Order by meta_id and keep the first row per key, empty included. Also read
non-scalar values as absent, matching the caller.
Refs #58284
* perf(product): Scan one candidate partition instead of pre-checking twice
Above 50 children the scan ran the same predicate twice on different data: the
data store's candidate query ordered the children, then the per-batch loop
re-derived the same split from the primed caches. Beyond the wasted pass over
every child, the two reads could disagree, and only one of them was ever the
one that mattered.
Priming was also done once per batch of 50. _prime_post_caches() costs three
queries for variations, since product_visibility, product_shipping_class and
pos_product_visibility are all registered against product_variation, so a
product with no purchasable variation paid three queries per batch: 36 queries
at 500 children and 92 at 1500, against trunk's three.
Partition the children once, from whichever read is available, and prime only
what is about to be hydrated: the first batch of candidates, then the rest of
them, then the deferred ones. Nothing is primed that the scan does not reach,
and a fully sold-out product now costs five queries instead of 92.
A data store that keeps variations outside postmeta no longer pays for a
pre-check that can never match; it falls back to trunk's single prime and
straight scan.
Refs #58284
* perf(product): Memoise the purchasable candidate list for the request
The candidate query has no cache layer, so it ran in full on every call for
the same product. A block single-product page calls has_purchasable_variations()
six times: AddToCartWithOptions, VariationSelector, QuantitySelector and
ProductButton through Utils::is_not_purchasable_product(), plus
ProductStockIndicator and the embedded Store API schema.
Each of those repeat calls cost two queries and, on a 1500-variation product,
about 12 ms, so the render spent more on the predicate than trunk did.
Cache the candidate list per request, keyed by product ID and a hash of the
children it was built from. Warm calls now cost no queries, the same as trunk.
Only the ordering is cached, never the answer: a stale list would still send
every variation through is_purchasable() and is_in_stock(), which is the
property that keeps woocommerce_is_purchasable filters working. The cache is
also unreachable under a persistent object cache, where the candidate query is
skipped anyway, so the value never outlives the request.
Refs #58284
* test(product): Pin the candidate query branch in the purchasable scan
Neither above-threshold test could tell the data store branch from a no-op.
With get_purchasable_scan_order() short-circuited to children order, both test
classes still passed all 101 tests: the scan-order test still hydrated one
because the second batch's pre-check put the last child first, and the
foreign-ID test never asked the store at all.
Count the calls on the store instead. The new test asserts none at 50
children, one at 51, still one on a repeat call in the same request, still one
under a simulated persistent object cache, and two after a cache flush that
removes the memo. It fails on the first assertion when the branch is disabled.
Restore wp_using_ext_object_cache() in a finally block and cast to bool,
because passing null is a no-op that would leak the simulated state into later
tests, as #65440 had to fix once already.
Refs #58284
* fix(product): Read serialized meta rows like get_post_meta does
85c2153a8a claimed to read non-scalar meta as absent so the query path and
the cache path would agree, but the guard it added cannot reject anything.
get_results() returns strings or null; is_scalar( null ) is false and (string)
null is '', which is already the else branch. Every real row takes the
scalar branch untouched.
A serialized array is a non-empty string here, so the query promotes such a
variation to a candidate while get_post_meta(), which unserializes first,
hands the pre-check an array and defers it. Reproduced with _regular_price
set to array( '10' ): a 51-child product scans that variation first, a
40-child product scans it last. The answer stays correct, since both paths
only pick scan order, but the two pre-checks disagree.
Unserialize before the scalar check, which is what get_post_meta() does.
Refs #58284
* test(product): Pin the second candidate batch in the purchasable scan
Every fixture with more than 50 children is all out of stock, so no test
yields more than one stored-state candidate and array_slice( $candidate_ids,
50 ) is always empty. Deleting that line leaves all 103 tests green, which
hides a wrong answer rather than a slower one: candidates past the first
batch are not in the deferred list either, so the scan would never reach
them and has_purchasable_variations() would return false for a product that
does have a purchasable variation.
Cover it with 60 candidates where a filter leaves only the 56th purchasable.
The hit lands in the second batch, so the test asserts both the answer and
that the scan stops there. It fails on the wrong answer with the slice
removed.
Refs #58284
* docs(product): Correct the priming note on has_purchasable_variations
The docblock still describes the two-prime shape from before 3015d016c1:
one prime for the first batch and one for everything else, so a product with
nothing purchasable costs one extra prime. The loop primes once per non-empty
batch, and there are three batches.
Counted on 300-child products with nothing purchasable: no candidates primes
once (300), 30 candidates primes twice (30, 270), 60 candidates primes three
times (50, 10, 240). The fallback path primes all children before the loop
instead.
Describe the per-batch shape and the fallback, so the cost the reader
computes from this docblock is the cost the loop has.
Refs #58284
* docs(product): Record why the candidate call guards on method_exists()
has_callable() is the house idiom for optional data store methods, and
06e42e23d5 moved off it without leaving a reason in the code. It is
is_callable() underneath, which answers true for every method name on a
store that declares __call(), so a store whose __call() throws fataled here.
Someone will read the guard as an oversight and simplify it back.
State the reason at the guard, and give the PHPStan suppression below it the
same note the other four method.notFound suppressions in the codebase carry.
Refs #58284
* feat(product): Log a failed stored-state read for purchasable candidates
Neither read in get_purchasable_variation_candidates() checks
$wpdb->last_error. A failing query degrades to the right answer, since an
empty state defers every variation and the scan still evaluates all of them,
so nothing breaks. That is also the problem: a query failing on every
request is indistinguishable from the slow path working as designed, and the
optimization would sit inert with no signal.
Log through wc_get_logger() after the reads, in the same shape the order
data store uses for its raw reads. The error has to be captured after each
call rather than once at the end, because wpdb::query() flushes last_error
before every query, so a clean second read would erase a failed first one.
Refs #58284
* perf(product): Classify and scan in one pass without a bulk read
get_stored_state_candidates() returns null, sending the call down the
fallback path, whenever the product has 50 or fewer children or a persistent
object cache is active. That is where nearly all real traffic lands: most
variable products are far below 50 children, and any store large enough for
this to matter runs Redis or Memcached. Neither shape is covered by the
measurements on this PR, which are all above 50 children with no object
cache.
That path materialised the whole partition before scanning any of it, so a
product whose first child is purchasable read every child's stored state to
answer a question the first child already answered. The six calls a block
single-product render makes multiplied it: a 300-child product on an
object-cached store ran 1,800 stored-state reads per render and hydrated the
same single variation trunk does.
Measured against a verbatim copy of trunk on the same fixtures, six calls per
render, median of 21:
12 children, all in stock +27%
40 children, all in stock +53%
40 children, none purchasable +13%
300 children, all in stock, object cache +60%
Everything on that path is primed up front, so batching buys nothing there
and testing each candidate as it is found costs no extra queries. Classify
and test in one pass, collecting non-candidates for the second pass. The
scan order is unchanged, candidates in children order then the rest, and
every variation is still evaluated before returning false.
The same fixtures now measure +0.4% to +4.3% on an early hit, -0.8% for the
300-child object-cached product, and +7.5% where nothing is purchasable,
which is the cost of classifying when classifying cannot help. The wins are
unchanged: -65% to -96% wherever the purchasable variation is late.
Refs #58284
* test(product): Restore suppress_errors through a finally block
The failed-read test flips $wpdb->suppress_errors() on, calls the data
store, and flips it back on the line after. The test case restores $wp_filter
between tests but never touches suppress_errors, so a throw from the call
under test would leave errors suppressed for the rest of the run, silently
masking database failures in unrelated tests.
Move the restore into a finally block, and remove the two filters there as
well so the whole setup unwinds in one place. This matches the equivalent
test for the order data store's last_error logging in
class-wc-abstract-order-test.php, which this logging was modelled on.
Refs #58284
* test(product): Cover candidate scan compatibility regressions
Existing store doubles declare the optional candidate method, so they
cannot detect unsafe capability checks on stores with magic dispatch.
The SQL metadata test also leaves cache-path parity and duplicate-row
selection unprotected.
Add a proxy store that rejects candidate lookup with a purchasable child
beyond the first batch. Exercise both stored-state classifiers against
16 datasets, including serialized values and duplicate metadata rows
in both insertion orders.
Verify the coverage by restoring the unsafe guard, reversing duplicate
ordering, and altering each normalizer independently. Each regression
fails the new tests; the restored implementation passes all 123 tests
in the two affected classes.
Refs #58284
* test(product): Respect table prefixes in failed-read coverage
The failed-read test matches literal wp_posts and wp_postmeta names,
while the production queries use the configured WordPress table prefix.
With another prefix, no query is interrupted and the logging assertion
fails because of the environment.
Resolve the posts and postmeta table names through wpdb in the query
filter. Preserve the exact-count assertions and independent read failures.
The affected class passes all 97 tests; dropping the first error capture
still fails the posts case.
Refs #58284
---------
Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/perf-58284-purchasable-variations-candidate-scan b/plugins/woocommerce/changelog/perf-58284-purchasable-variations-candidate-scan
new file mode 100644
index 00000000000..a538a6dcc77
--- /dev/null
+++ b/plugins/woocommerce/changelog/perf-58284-purchasable-variations-candidate-scan
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Check likely-purchasable variations first in WC_Product_Variable::has_purchasable_variations() so listings and Store API responses stop hydrating every variation of each variable product.
diff --git a/plugins/woocommerce/includes/class-wc-product-variable.php b/plugins/woocommerce/includes/class-wc-product-variable.php
index 9c0bb2a8b87..f03445fecc5 100644
--- a/plugins/woocommerce/includes/class-wc-product-variable.php
+++ b/plugins/woocommerce/includes/class-wc-product-variable.php
@@ -387,9 +387,31 @@ 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
@@ -403,22 +425,162 @@ 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.
*/
- $has_purchasable_variations = false;
- $variation_ids = $this->get_children();
- if ( ! empty( $variation_ids ) ) {
+ $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;
+ }
+
// Prime caches to reduce future queries.
- _prime_post_caches( $variation_ids );
+ _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;
+ }
- 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;
- }
+ $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 : '';
+ }
+
+ /**
+ * 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;
}
}
- return $has_purchasable_variations;
+ 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();
}
/**
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 1c0f53bb3c6..8d11ae54915 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
@@ -829,6 +829,124 @@ 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 da5e45fbd42..f95ac444f89 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,5 +1,8 @@
<?php
+use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Enums\ProductStockStatus;
+
/**
* Tests for WC_Product_Variable.
*/
@@ -406,4 +409,447 @@ 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 83bd4cb2040..31ff1ca3a1d 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,5 +1,6 @@
<?php
+use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductStockStatus;
/**
@@ -1992,4 +1993,223 @@ 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.' );
+ }
}