Skip to content

Commit 19c9286

Browse files
authored
Merge pull request #4121 from craftcms/nathaniel/com-288-5x-add-catalog-pricing-rule-info-to-purchasableline-item
[5.x] Improve performance of product and variant queries
2 parents 5b8d9f2 + e8e51e2 commit 19c9286

9 files changed

Lines changed: 248 additions & 74 deletions

File tree

CHANGELOG-WIP.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
### Development
1313
- Orders now have a `dateFirstPaid` property that records the date and time when the order was first paid in full.
14+
- Improved product and variant query performance.
15+
- Improved the performance of retrieving a line item’s catalog pricing rule ID.
1416
- Added the `children`, `parent`, `ancestors` and `descendants` fields to products’ GraphQL data. ([#4122](https://github.com/craftcms/commerce/issues/4122))
1517
- Added the `--force` option to the `commerce/reset-data` command. ([#4115](https://github.com/craftcms/commerce/discussions/4115))
1618

@@ -22,6 +24,7 @@
2224
- Added `craft\commerce\events\UpdateInventoryLevelEvent`. ([#4063](https://github.com/craftcms/commerce/pull/4063))
2325
- Added `craft\commerce\helpers\Gql::getSchemaContainedProductTypes()`.
2426
- Added `craft\commerce\records\Order::$dateFirstPaid`.
27+
- Added `craft\commerce\services\CatalogPricingRules::hasCatalogPricingRules()`.
2528
- Added `craft\commerce\services\Discounts::appendCouponCode()`. ([#4084](https://github.com/craftcms/commerce/pull/4084))
2629
- Added `craft\commerce\services\Inventory::EVENT_AFTER_EXECUTE_INVENTORY_MOVEMENT`. ([#4063](https://github.com/craftcms/commerce/pull/4063))
2730
- Added `craft\commerce\services\Inventory::EVENT_AFTER_EXECUTE_UPDATE_INVENTORY_LEVEL`. ([#4063](https://github.com/craftcms/commerce/pull/4063))

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- Fixed a bug where variants weren’t getting duplicated correctly. ([#4125](https://github.com/craftcms/commerce/issues/4125))
2222
- Fixed a SQL error that could occur when deleting a shipping method.
2323

24+
2425
## 5.4.6 - 2025-09-04
2526

2627
- Fixed a bug where the `commerce/cart/update-cart` action could return unnecessary validation errors. ([3873](https://github.com/craftcms/commerce/issues/3873))

src/elements/db/ProductQuery.php

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -721,13 +721,18 @@ protected function afterPrepare(): bool
721721
// Store dependent related joins to the sub query need to be done after the `elements_sites` is joined in the base `ElementQuery` class.
722722
$customerId = Craft::$app->getUser()->getIdentity()?->id;
723723

724-
$catalogPricesQuery = Plugin::getInstance()
725-
->getCatalogPricing()
726-
->createCatalogPricesQuery(userId: $customerId)
727-
->addSelect(['cp.purchasableId', 'cp.storeId']);
728-
729724
$this->subQuery->leftJoin(['sitestores' => Table::SITESTORES], '[[elements_sites.siteId]] = [[sitestores.siteId]]');
730-
$this->subQuery->leftJoin(['catalogprices' => $catalogPricesQuery], '[[catalogprices.purchasableId]] = [[commerce_products.defaultVariantId]] AND [[catalogprices.storeId]] = [[sitestores.storeId]]');
725+
726+
if (Plugin::getInstance()->getCatalogPricingRules()->hasCatalogPricingRules()) {
727+
$catalogPricesQuery = Plugin::getInstance()
728+
->getCatalogPricing()
729+
->createCatalogPricesQuery(userId: $customerId)
730+
->addSelect(['cp.purchasableId', 'cp.storeId']);
731+
732+
$this->subQuery->leftJoin(['catalogprices' => $catalogPricesQuery], '[[catalogprices.purchasableId]] = [[commerce_products.defaultVariantId]] AND [[catalogprices.storeId]] = [[sitestores.storeId]]');
733+
} else {
734+
$this->subQuery->leftJoin(['purchasablesstores' => Table::PURCHASABLES_STORES], '[[purchasablesstores.storeId]] = [[sitestores.storeId]] AND [[purchasablesstores.purchasableId]] = [[commerce_products.defaultVariantId]]');
735+
}
731736

732737
return parent::afterPrepare();
733738
}
@@ -752,7 +757,6 @@ protected function beforePrepare(): bool
752757
'commerce_products.typeId',
753758
'commerce_products.postDate',
754759
'commerce_products.expiryDate',
755-
'subquery.price as defaultPrice',
756760
'purchasablesstores.basePrice as defaultBasePrice',
757761
'purchasablesstores.basePromotionalPrice as defaultBasePromotionalPrice',
758762
'commerce_products.defaultVariantId',
@@ -769,7 +773,21 @@ protected function beforePrepare(): bool
769773
$this->query->leftJoin(['purchasables' => Table::PURCHASABLES], '[[purchasables.id]] = [[commerce_products.defaultVariantId]]');
770774
$this->query->leftJoin(['purchasablesstores' => Table::PURCHASABLES_STORES], '[[purchasablesstores.purchasableId]] = [[commerce_products.defaultVariantId]] and [[sitestores.storeId]] = [[purchasablesstores.storeId]]');
771775

772-
$this->subQuery->addSelect(['catalogprices.price']);
776+
// Tailor the query based on whether or not there is catalog pricing rules
777+
if (Plugin::getInstance()->getCatalogPricingRules()->hasCatalogPricingRules()) {
778+
$this->query->addSelect(['subquery.price as defaultPrice']);
779+
$this->subQuery->addSelect(['catalogprices.price']);
780+
781+
if (isset($this->defaultPrice)) {
782+
$this->subQuery->andWhere(Db::parseParam('catalogprices.price', $this->defaultPrice));
783+
}
784+
} else {
785+
$this->query->addSelect(['purchasablesstores.basePrice as defaultPrice']);
786+
787+
if (isset($this->defaultPrice)) {
788+
$this->subQuery->andWhere(Db::parseParam('purchasablesstores.basePrice', $this->defaultPrice));
789+
}
790+
}
773791

774792
if (isset($this->postDate)) {
775793
$this->subQuery->andWhere(Db::parseDateParam('commerce_products.postDate', $this->postDate));
@@ -785,10 +803,6 @@ protected function beforePrepare(): bool
785803
$this->subQuery->leftJoin(['purchasables' => Table::PURCHASABLES], '[[purchasables.id]] = [[commerce_products.defaultVariantId]]');
786804
}
787805

788-
if (isset($this->defaultPrice)) {
789-
$this->subQuery->andWhere(Db::parseParam('catalogprices.price', $this->defaultPrice));
790-
}
791-
792806
if (isset($this->defaultHeight)) {
793807
$this->subQuery->andWhere(Db::parseParam('purchasables.height', $this->defaultHeight));
794808
}

src/elements/db/PurchasableQuery.php

Lines changed: 119 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use craft\commerce\Plugin;
1616
use craft\db\Query;
1717
use craft\elements\db\ElementQuery;
18+
use craft\helpers\ArrayHelper;
1819
use craft\helpers\Db;
1920
use yii\db\Connection;
2021
use yii\db\Expression;
@@ -654,22 +655,26 @@ public function onPromotion(bool|null $value = true): static
654655
protected function afterPrepare(): bool
655656
{
656657
// Store dependent related joins to the sub query need to be done after the `elements_sites` is joined in the base `ElementQuery` class.
657-
$customerId = $this->forCustomer;
658-
if ($customerId === null) {
659-
$customerId = Craft::$app->getUser()->getIdentity()?->id;
660-
} elseif ($customerId === false) {
661-
$customerId = null;
662-
}
663-
664-
$catalogPricesQuery = Plugin::getInstance()
665-
->getCatalogPricing()
666-
->createCatalogPricesQuery(userId: $customerId)
667-
->addSelect(['cp.purchasableId', 'cp.storeId']);
668-
669658
$this->subQuery->leftJoin(['sitestores' => Table::SITESTORES], '[[elements_sites.siteId]] = [[sitestores.siteId]]');
670659
$this->subQuery->leftJoin(['purchasables_stores' => Table::PURCHASABLES_STORES], '[[purchasables_stores.storeId]] = [[sitestores.storeId]] AND [[purchasables_stores.purchasableId]] = [[commerce_purchasables.id]]');
671660

672-
$this->subQuery->leftJoin(['catalogprices' => $catalogPricesQuery], '[[catalogprices.purchasableId]] = [[commerce_purchasables.id]] AND [[catalogprices.storeId]] = [[sitestores.storeId]]');
661+
// Only do the extra catalog pricing query join if we have catalog pricing rules.
662+
if (Plugin::getInstance()->getCatalogPricingRules()->hasCatalogPricingRules()) {
663+
$customerId = $this->forCustomer;
664+
if ($customerId === null) {
665+
$customerId = Craft::$app->getUser()->getIdentity()?->id;
666+
} elseif ($customerId === false) {
667+
$customerId = null;
668+
}
669+
670+
$catalogPricesQuery = Plugin::getInstance()
671+
->getCatalogPricing()
672+
->createCatalogPricesQuery(userId: $customerId)
673+
->addSelect(['cp.purchasableId', 'cp.storeId']);
674+
675+
$this->subQuery->leftJoin(['catalogprices' => $catalogPricesQuery], '[[catalogprices.purchasableId]] = [[commerce_purchasables.id]] AND [[catalogprices.storeId]] = [[sitestores.storeId]]');
676+
}
677+
673678
$this->subQuery->leftJoin(['inventoryitems' => Table::INVENTORYITEMS], '[[inventoryitems.purchasableId]] = [[commerce_purchasables.id]]');
674679

675680
return parent::afterPrepare();
@@ -698,41 +703,81 @@ protected function beforePrepare(): bool
698703
'purchasables_stores.allowOutOfStockPurchases',
699704
'purchasables_stores.promotable',
700705
'purchasables_stores.shippingCategoryId',
701-
'subquery.price',
702-
'subquery.promotionalPrice as promotionalPrice',
703-
'subquery.salePrice as salePrice',
704-
'catprice.catalogPricingRuleId as catalogPricingRuleId',
705706
'inventoryitems.id as inventoryItemId',
706707
]);
707708

708709
$this->query->leftJoin(Table::SITESTORES . ' sitestores', '[[elements_sites.siteId]] = [[sitestores.siteId]]');
709710
$this->query->leftJoin(Table::PURCHASABLES_STORES . ' purchasables_stores', '[[purchasables_stores.storeId]] = [[sitestores.storeId]] AND [[purchasables_stores.purchasableId]] = [[commerce_purchasables.id]]');
710711
$this->query->leftJoin(['inventoryitems' => Table::INVENTORYITEMS], '[[inventoryitems.purchasableId]] = [[commerce_purchasables.id]]');
711712

712-
// Retrieve the catalog pricing rule ID used to determine the price
713-
$customerId = $this->forCustomer;
714-
if ($customerId === null) {
715-
$customerId = Craft::$app->getUser()->getIdentity()?->id;
716-
} elseif ($customerId === false) {
717-
$customerId = null;
713+
// Only do the extra catalog pricing query join if we have catalog pricing rules.
714+
if (Plugin::getInstance()->getCatalogPricingRules()->hasCatalogPricingRules()) {
715+
$this->query->addSelect([
716+
'subquery.price',
717+
'subquery.promotionalPrice as promotionalPrice',
718+
'subquery.salePrice as salePrice',
719+
]);
720+
$this->subQuery->addSelect([
721+
'catalogprices.price',
722+
'catalogprices.promotionalPrice',
723+
'catalogprices.salePrice',
724+
]);
725+
726+
if (isset($this->price)) {
727+
$this->subQuery->andWhere(Db::parseNumericParam('catalogprices.price', $this->price));
728+
}
729+
730+
if (isset($this->promotionalPrice)) {
731+
$this->subQuery->andWhere(Db::parseNumericParam('catalogprices.promotionalPrice', $this->promotionalPrice));
732+
}
733+
734+
if (isset($this->onPromotion)) {
735+
if ($this->onPromotion) {
736+
$this->subQuery->andWhere(new Expression('[[catalogprices.promotionalPrice]] < [[catalogprices.price]]'));
737+
} else {
738+
// Commerce normalizes these when selecting/aggregating, so the values will actually be the same when a promotional price doesn't exist. This means it's not technically possible to distinguish between an *unset* promotional price and a promotional price that ended up being the same as the regular price. It’s also ambiguous when a pricing rule sets a `promotionalPrice` based on the original `price`!
739+
$this->subQuery->andWhere(new Expression('[[catalogprices.price]] = [[catalogprices.promotionalPrice]]'));
740+
}
741+
}
742+
743+
if (isset($this->salePrice)) {
744+
$this->subQuery->andWhere(Db::parseNumericParam('catalogprices.salePrice' , $this->salePrice));
745+
}
746+
} else {
747+
// If Catalog pricing rules are not being used
748+
$this->query->addSelect([
749+
'purchasables_stores.basePrice as price',
750+
'purchasables_stores.basePromotionalPrice as promotionalPrice',
751+
new Expression('CASE WHEN [[purchasables_stores.basePromotionalPrice]] < [[purchasables_stores.basePrice]] THEN [[purchasables_stores.basePromotionalPrice]] ELSE [[purchasables_stores.basePrice]] END as [[salePrice]]'),
752+
new Expression('null as [[catalogPricingRuleId]]'),
753+
]);
754+
755+
$this->subQuery->addSelect([
756+
'purchasables_stores.basePrice as price',
757+
'purchasables_stores.basePromotionalPrice as promotionalPrice',
758+
new Expression('CASE WHEN [[purchasables_stores.basePromotionalPrice]] < [[purchasables_stores.basePrice]] THEN [[purchasables_stores.basePromotionalPrice]] ELSE [[purchasables_stores.basePrice]] END as [[salePrice]]'),
759+
]);
760+
761+
if (isset($this->price)) {
762+
$this->subQuery->andWhere(Db::parseNumericParam('purchasables_stores.basePrice', $this->price));
763+
}
764+
765+
if (isset($this->promotionalPrice)) {
766+
$this->subQuery->andWhere(Db::parseNumericParam('purchasables_stores.basePromotionalPrice', $this->promotionalPrice));
767+
}
768+
769+
if (isset($this->onPromotion)) {
770+
if ($this->onPromotion) {
771+
$this->subQuery->andWhere(new Expression('[[purchasables_stores.basePromotionalPrice]] < [[purchasables_stores.basePrice]]'));
772+
} else {
773+
$this->subQuery->andWhere(new Expression('[[purchasables_stores.basePrice]] < [[purchasables_stores.basePromotionalPrice]]'));
774+
}
775+
}
776+
777+
if (isset($this->salePrice)) {
778+
$this->subQuery->andWhere(Db::parseNumericParam(new Expression('CASE WHEN [[purchasables_stores.basePromotionalPrice]] < [[purchasables_stores.basePrice]] THEN [[purchasables_stores.basePromotionalPrice]] ELSE [[purchasables_stores.basePrice]] END') , $this->salePrice));
779+
}
718780
}
719-
$cprIdQuery = Plugin::getInstance()
720-
->getCatalogPricing()
721-
->createCatalogPricesQuery(userId: $customerId)
722-
->select([
723-
'purchasableId',
724-
'storeId',
725-
'price',
726-
new Expression('MIN([[catalogPricingRuleId]]) as [[catalogPricingRuleId]]'),
727-
])
728-
->groupBy(['cp.purchasableId', 'cp.storeId', 'cp.price']);
729-
$this->query->leftJoin(['catprice' => $cprIdQuery], '[[catprice.purchasableId]] = [[commerce_purchasables.id]] AND [[catprice.storeId]] = [[sitestores.storeId]] AND [[catprice.price]] = [[subquery.salePrice]]');
730-
731-
$this->subQuery->addSelect([
732-
'catalogprices.price',
733-
'catalogprices.promotionalPrice',
734-
'catalogprices.salePrice',
735-
]);
736781

737782
if (isset($this->sku)) {
738783
$this->subQuery->andWhere(Db::parseParam('commerce_purchasables.sku', $this->sku));
@@ -755,27 +800,6 @@ protected function beforePrepare(): bool
755800
$this->subQuery->andWhere(Db::parseParam('commerce_purchasables.sku', $this->sku));
756801
}
757802

758-
if (isset($this->price)) {
759-
$this->subQuery->andWhere(Db::parseNumericParam('catalogprices.price', $this->price));
760-
}
761-
762-
if (isset($this->promotionalPrice)) {
763-
$this->subQuery->andWhere(Db::parseNumericParam('catalogprices.promotionalPrice', $this->promotionalPrice));
764-
}
765-
766-
if (isset($this->onPromotion)) {
767-
if ($this->onPromotion) {
768-
$this->subQuery->andWhere(new Expression('[[catalogprices.promotionalPrice]] < [[catalogprices.price]]'));
769-
} else {
770-
// Commerce normalizes these when selecting/aggregating, so the values will actually be the same when a promotional price doesn't exist. This means it's not technically possible to distinguish between an *unset* promotional price and a promotional price that ended up being the same as the regular price. It’s also ambiguous when a pricing rule sets a `promotionalPrice` based on the original `price`!
771-
$this->subQuery->andWhere(new Expression('[[catalogprices.price]] = [[catalogprices.promotionalPrice]]'));
772-
}
773-
}
774-
775-
if (isset($this->salePrice)) {
776-
$this->subQuery->andWhere(Db::parseNumericParam('catalogprices.salePrice' , $this->salePrice));
777-
}
778-
779803
if (isset($this->shippingCategoryId)) {
780804
if ($this->shippingCategoryId instanceof Query) {
781805
$shippingCategoryWhere = ['exists', $this->shippingCategoryId];
@@ -856,6 +880,40 @@ protected function beforePrepare(): bool
856880
*/
857881
public function populate($rows): array
858882
{
883+
if (!empty($rows) && Plugin::getInstance()->getCatalogPricingRules()->hasCatalogPricingRules()) {
884+
$row = ArrayHelper::firstValue($rows);
885+
$store = Plugin::getInstance()->getStores()->getStoreBySiteId($row['siteId']);
886+
$purchasableIds = ArrayHelper::getColumn($rows, 'id');
887+
$customerId = $this->forCustomer;
888+
if ($customerId === null) {
889+
$customerId = Craft::$app->getUser()->getIdentity()?->id;
890+
} elseif ($customerId === false) {
891+
$customerId = null;
892+
}
893+
$cprIds = Plugin::getInstance()
894+
->getCatalogPricing()
895+
->createCatalogPricesQuery(userId: $customerId, storeId: $store->id)
896+
->select([
897+
'purchasableId',
898+
'storeId',
899+
'price',
900+
new Expression('MIN([[catalogPricingRuleId]]) as [[catalogPricingRuleId]]'),
901+
])
902+
->andWhere(['purchasableId' => $purchasableIds])
903+
->andWhere(['not', ['catalogPricingRuleId' => null]])
904+
->groupBy(['cp.purchasableId', 'cp.storeId', 'cp.price'])
905+
->all();
906+
907+
foreach ($cprIds as $cprId) {
908+
foreach ($rows as &$row) {
909+
if ($row['id'] == $cprId['purchasableId']) {
910+
$row['catalogPricingRuleId'] = $cprId['catalogPricingRuleId'];
911+
break;
912+
}
913+
}
914+
}
915+
}
916+
859917
foreach ($rows as &$row) {
860918
unset($row['salePrice']);
861919
}

src/elements/db/VariantQuery.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,14 @@ protected function beforePrepare(): bool
432432
'elements_owners.sortOrder',
433433
])
434434
->innerJoin(['elements_owners' => CraftTable::ELEMENTS_OWNERS], $ownersCondition);
435-
$this->subQuery->innerJoin(['elements_owners' => CraftTable::ELEMENTS_OWNERS], $ownersCondition);
435+
436+
$sortOrderIndex = Db::findIndex(CraftTable::ELEMENTS_OWNERS, ['sortOrder'], false);
437+
// Forcing the use of the `sortOrder` index if no custom `orderBy` is set
438+
if ($sortOrderIndex !== null && empty($this->orderBy)) {
439+
$this->subQuery->innerJoin([new Expression('[[elements_owners]] USE INDEX (' . $sortOrderIndex . ')')], $ownersCondition);
440+
} else {
441+
$this->subQuery->innerJoin(['elements_owners' => CraftTable::ELEMENTS_OWNERS], $ownersCondition);
442+
}
436443

437444
if ($this->primaryOwnerId) {
438445
$this->subQuery->andWhere(['commerce_variants.primaryOwnerId' => $this->primaryOwnerId]);

src/services/CatalogPricingRules.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,28 @@
3636
*/
3737
class CatalogPricingRules extends Component
3838
{
39+
/**
40+
* @var bool|null
41+
*/
42+
private ?bool $_hasCatalogPricingRules = null;
43+
44+
/**
45+
* @return bool
46+
* @throws InvalidConfigException
47+
*/
48+
public function hasCatalogPricingRules(): bool
49+
{
50+
if (!$this->canUseCatalogPricingRules()) {
51+
return false;
52+
}
53+
54+
if ($this->_hasCatalogPricingRules === null) {
55+
$this->_hasCatalogPricingRules = $this->_createCatalogPricingRuleQuery()->exists();
56+
}
57+
58+
return (bool)$this->_hasCatalogPricingRules;
59+
}
60+
3961
/**
4062
* @var Collection[]|null
4163
*/
@@ -353,6 +375,7 @@ protected function _createCatalogPricingRuleQuery(): ?Query
353375
protected function _clearCaches(): void
354376
{
355377
$this->_allCatalogPricingRules = null;
378+
$this->_hasCatalogPricingRules = null;
356379
}
357380

358381
/**

0 commit comments

Comments
 (0)