mirror of
https://github.com/wp-graphql/wp-graphql-woocommerce.git
synced 2026-08-14 12:53:44 +02:00
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
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Tests that querying variable products with many variations does not
|
||||
* trigger 429 errors from excessive response times or resource usage.
|
||||
*
|
||||
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/897
|
||||
*/
|
||||
class VariableProductPerformanceCest {
|
||||
public function _before( FunctionalTester $I ) {
|
||||
$I->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)." );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Tests performance of querying variable products with many variations.
|
||||
*
|
||||
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/897
|
||||
*/
|
||||
class VariableProductPerformanceTest extends \Tests\WPGraphQL\WooCommerce\TestCase\WooGraphQLTestCase {
|
||||
private $product_ids = [];
|
||||
|
||||
public function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// Create 15 variable products each with 20+ variations.
|
||||
for ( $i = 0; $i < 15; $i++ ) {
|
||||
$product_id = $this->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." );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user