From 858887fa78b9c90400b4a4aff58507dfa46febfc Mon Sep 17 00:00:00 2001 From: Geoff Taylor Date: Tue, 31 Mar 2026 09:17:44 -0400 Subject: [PATCH] devops: Variable product performance optimization (#1006) * devops: Variable product performance optimization and tests - Memoize get_variation_prices() in the Product model to avoid redundant lookups across multiple pricing fields (price, regularPrice, salePrice, and their RAW variants) - Remove redundant post__in filter from variations connection resolver; post_parent already constrains the query, and the post__in triggered an extra get_children() call per product - Add createVariableProductCatalog() helper to GraphQLE2E for creating variable products with many variations in tests - Add VariableProductPerformanceTest (wpunit) measuring DB query count and duration for 15 variable products with 18 variations each - Add VariableProductPerformanceCest (functional) verifying 6 rapid queries do not return 429 errors Addresses #897 * fix: Skip timing assertion when xdebug is active and clean up debug code --- includes/model/class-product.php | 13 +- .../class-product-with-variations.php | 1 - tests/_support/Helper/GraphQLE2E.php | 77 +++++++++++ .../VariableProductPerformanceCest.php | 59 ++++++++ .../wpunit/VariableProductPerformanceTest.php | 129 ++++++++++++++++++ 5 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 tests/functional/VariableProductPerformanceCest.php create mode 100644 tests/wpunit/VariableProductPerformanceTest.php diff --git a/includes/model/class-product.php b/includes/model/class-product.php index 1423a581..03d01941 100644 --- a/includes/model/class-product.php +++ b/includes/model/class-product.php @@ -103,6 +103,14 @@ class Product extends WC_Post { */ protected $product_type; + /** + * Cached variation prices to avoid redundant lookups across + * multiple pricing fields (price, regularPrice, salePrice, etc.). + * + * @var array|null + */ + private $variation_prices = null; + /** * Stores product factory. * @@ -164,7 +172,10 @@ class Product extends WC_Post { */ $data = $this->wc_data; - $prices = $data->get_variation_prices( true ); + if ( is_null( $this->variation_prices ) ) { + $this->variation_prices = $data->get_variation_prices( true ); + } + $prices = $this->variation_prices; if ( empty( $prices['price'] ) || ( 'sale' === $pricing_type && ! $this->wc_data->is_on_sale() ) ) { return null; diff --git a/includes/type/interface/class-product-with-variations.php b/includes/type/interface/class-product-with-variations.php index 5ff1e482..557069c5 100644 --- a/includes/type/interface/class-product-with-variations.php +++ b/includes/type/interface/class-product-with-variations.php @@ -75,7 +75,6 @@ class Product_With_Variations { $resolver->set_query_arg( 'post_parent', $source->ID ); $resolver->set_query_arg( 'post_type', 'product_variation' ); - $resolver->set_query_arg( 'post__in', $source->variation_ids ); return $resolver->get_connection(); }, diff --git a/tests/_support/Helper/GraphQLE2E.php b/tests/_support/Helper/GraphQLE2E.php index 1338eb5c..e8193194 100644 --- a/tests/_support/Helper/GraphQLE2E.php +++ b/tests/_support/Helper/GraphQLE2E.php @@ -1309,4 +1309,81 @@ class GraphQLE2E extends \Codeception\Module { return $cart_page_id; } + + /** + * Creates variable products with many variations for performance testing. + * + * @param int $product_count Number of variable products to create. + * @param int $variations_per Approximate variations per product (3 attrs: sizes × colors × styles). + * + * @return array Product IDs. + */ + public function createVariableProductCatalog( $product_count = 10, $variations_per = 18 ) { + $product_ids = []; + + for ( $p = 0; $p < $product_count; $p++ ) { + $product = new \WC_Product_Variable(); + $product->set_name( "Variable Product {$p}" ); + $product->set_status( 'publish' ); + + $size_attr = new \WC_Product_Attribute(); + $size_attr->set_name( 'Size' ); + $size_attr->set_options( [ 'Small', 'Medium', 'Large' ] ); + $size_attr->set_visible( true ); + $size_attr->set_variation( true ); + + $color_attr = new \WC_Product_Attribute(); + $color_attr->set_name( 'Color' ); + $color_attr->set_options( [ 'Red', 'Blue', 'Green' ] ); + $color_attr->set_visible( true ); + $color_attr->set_variation( true ); + + $style_attr = new \WC_Product_Attribute(); + $style_attr->set_name( 'Style' ); + $style_attr->set_options( [ 'Classic', 'Modern' ] ); + $style_attr->set_visible( true ); + $style_attr->set_variation( true ); + + $product->set_attributes( [ $size_attr, $color_attr, $style_attr ] ); + $product_id = $product->save(); + + // 3 sizes × 3 colors × 2 styles = 18 variations per product. + $sizes = [ 'Small', 'Medium', 'Large' ]; + $colors = [ 'Red', 'Blue', 'Green' ]; + $styles = [ 'Classic', 'Modern' ]; + $count = 0; + + foreach ( $sizes as $size ) { + foreach ( $colors as $color ) { + foreach ( $styles as $style ) { + if ( $count >= $variations_per ) { + break 3; + } + + $variation = new \WC_Product_Variation(); + $variation->set_parent_id( $product_id ); + $variation->set_attributes( + [ + 'size' => $size, + 'color' => $color, + 'style' => $style, + ] + ); + $variation->set_regular_price( 10 + $count ); + if ( $count % 3 === 0 ) { + $variation->set_sale_price( 8 + $count ); + } + $variation->set_stock_status( 'instock' ); + $variation->save(); + $count++; + } + } + } + + wc_delete_product_transients( $product_id ); + $product_ids[] = $product_id; + } + + return $product_ids; + } } diff --git a/tests/functional/VariableProductPerformanceCest.php b/tests/functional/VariableProductPerformanceCest.php new file mode 100644 index 00000000..86279301 --- /dev/null +++ b/tests/functional/VariableProductPerformanceCest.php @@ -0,0 +1,59 @@ +createVariableProductCatalog( 10, 18 ); + } + + /** + * Test that sending multiple rapid product queries does not return 429 errors. + */ + public function testRapidVariableProductQueriesDoNotReturn429( FunctionalTester $I ) { + $query = ' + query { + products(first: 10, where: { type: VARIABLE }) { + nodes { + ... on VariableProduct { + databaseId + name + price + regularPrice + salePrice + variations(first: 5) { + nodes { + databaseId + price + regularPrice + } + } + } + } + } + } + '; + + // Fire 6 rapid queries — the reporter says 429 hits at ~6-7 executions. + $error_count = 0; + for ( $i = 0; $i < 6; $i++ ) { + $response = $I->sendGraphQLRequest( $query, null ); + + if ( empty( $response ) || ! isset( $response['data'] ) ) { + $error_count++; + codecept_debug( "Request {$i} failed or returned non-JSON response." ); + continue; + } + + $I->assertArrayHasKey( 'products', $response['data'] ); + $nodes = $response['data']['products']['nodes']; + $I->assertNotEmpty( $nodes, "Request {$i} returned empty products." ); + } + + $I->assertEquals( 0, $error_count, "{$error_count} of 6 rapid queries returned errors (likely 429)." ); + } +} diff --git a/tests/wpunit/VariableProductPerformanceTest.php b/tests/wpunit/VariableProductPerformanceTest.php new file mode 100644 index 00000000..67b9c5ad --- /dev/null +++ b/tests/wpunit/VariableProductPerformanceTest.php @@ -0,0 +1,129 @@ +factory->product->createVariable(); + $product = wc_get_product( $product_id ); + + // createVariable creates size (small/medium/large), color (red/blue/green), logo (Yes/No) + // That's 3x3x2 = 18 combinations. createSome only creates a few. + // Let's create all 18 variations + a few extra with different prices. + $sizes = [ 'small', 'medium', 'large' ]; + $colors = [ 'red', 'blue', 'green' ]; + $logos = [ 'Yes', 'No' ]; + + $variation_count = 0; + foreach ( $sizes as $size ) { + foreach ( $colors as $color ) { + foreach ( $logos as $logo ) { + $regular_price = 10 + $variation_count; + $sale_price = $variation_count % 3 === 0 ? $regular_price - 2 : ''; + + $this->factory->product_variation->create( + [ + 'parent_id' => $product_id, + 'attributes' => [ + 'pa_size' => $size, + 'pa_color' => $color, + 'logo' => $logo, + ], + 'regular_price' => $regular_price, + 'sale_price' => $sale_price, + ] + ); + $variation_count++; + } + } + } + + // Clear transients so prices are recalculated. + delete_transient( 'wc_var_prices_' . $product_id ); + wc_delete_product_transients( $product_id ); + + $this->product_ids[] = $product_id; + } + } + + /** + * Test that querying many variable products with pricing fields completes + * in a reasonable time and does not generate excessive DB queries. + */ + public function testQueryManyVariableProductsWithPricing() { + $query = ' + query { + products(first: 15, where: { type: VARIABLE }) { + nodes { + ... on VariableProduct { + databaseId + name + price + regularPrice + salePrice + priceRaw: price(format: RAW) + regularPriceRaw: regularPrice(format: RAW) + salePriceRaw: salePrice(format: RAW) + variations(first: 5) { + nodes { + databaseId + price + regularPrice + salePrice + } + } + } + } + } + } + '; + + // Track DB queries. + global $wpdb; + $wpdb->queries = []; + if ( ! defined( 'SAVEQUERIES' ) ) { + define( 'SAVEQUERIES', true ); + } + + $start = microtime( true ); + $response = $this->graphql( [ 'query' => $query ] ); + $duration = microtime( true ) - $start; + + $query_count = count( $wpdb->queries ); + + codecept_debug( "Query duration: {$duration}s, DB queries: {$query_count}" ); + + // Verify the response is successful. + $this->assertArrayHasKey( 'data', $response ); + $this->assertArrayHasKey( 'products', $response['data'] ); + + $nodes = $response['data']['products']['nodes']; + $this->assertCount( 15, $nodes ); + + // Verify pricing fields are resolved. + foreach ( $nodes as $node ) { + $this->assertNotEmpty( $node['databaseId'] ); + $this->assertNotNull( $node['price'] ); + $this->assertNotNull( $node['regularPrice'] ); + } + + // Performance assertion: skip when xdebug is active since it adds ~5x overhead. + if ( ! extension_loaded( 'xdebug' ) ) { + $this->assertLessThan( 5.0, $duration, "Query took {$duration}s — too slow for 15 variable products." ); + } + + // DB query count guard: prevents regressions. The ~212 query baseline + // includes WP template lookups, HPOS compat checks, and WC transient + // reads that are outside our control. This threshold catches N+1 regressions. + $this->assertLessThan( 250, $query_count, "Generated {$query_count} DB queries — possible N+1 regression." ); + } +}