From d8e3557acb02599fe145b60efe1207bada6b2a3e Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 13 Nov 2025 11:58:09 +0000 Subject: [PATCH 1/2] #4158 `productStatus` variant query param --- CHANGELOG-WIP.md | 2 + src/elements/db/VariantQuery.php | 135 ++++++++++++++++++ src/gql/arguments/elements/Variant.php | 5 + .../elements/variant/VariantQueryTest.php | 35 +++++ 4 files changed, 177 insertions(+) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 3da54487c5..5df2e50ab6 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -24,6 +24,8 @@ - Improved product and variant query performance. - Improved the performance of retrieving a line item’s catalog pricing rule ID. - Added the `children`, `parent`, `ancestors` and `descendants` fields to products’ GraphQL data. ([#4122](https://github.com/craftcms/commerce/issues/4122)) +- Added the `productStatus` variant query param. ([#4158](https://github.com/craftcms/commerce/issues/4158)) +- Added the `productStatus` GraphQL variant query argument. ([#4158](https://github.com/craftcms/commerce/issues/4158)) - Added the `--force` option to the `commerce/reset-data` command. ([#4115](https://github.com/craftcms/commerce/discussions/4115)) ### Extensibility diff --git a/src/elements/db/VariantQuery.php b/src/elements/db/VariantQuery.php index bede599dc2..526d57f224 100755 --- a/src/elements/db/VariantQuery.php +++ b/src/elements/db/VariantQuery.php @@ -17,9 +17,11 @@ use craft\commerce\Plugin; use craft\commerce\records\Sale; use craft\db\Query; +use craft\db\QueryAbortedException; use craft\db\Table as CraftTable; use craft\helpers\ArrayHelper; use craft\helpers\Db; +use craft\helpers\StringHelper; use DateTime; use yii\base\InvalidArgumentException; use yii\base\InvalidConfigException; @@ -95,6 +97,13 @@ class VariantQuery extends PurchasableQuery */ public mixed $ownerId = null; + /** + * @var mixed|null The status the owner product must have. + * @used-by productStatus() + * @since 5.5.0 + */ + public array|string|null $productStatus = null; + /** * @var mixed */ @@ -257,6 +266,43 @@ public function ownerId(mixed $value): VariantQuery return $this; } + /** + * Narrows the query results based on the {elements}’ product’s statuses. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `'enabled'` _(default)_ | that are enabled. + * | `'disabled'` | that are disabled. + * | `['not', 'disabled']` | that are not disabled. + * + * --- + * + * ```twig + * {# Fetch {elements} with disabled products #} + * {% set {elements-var} = {twig-method} + * .productStatus('disabled') + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} with disabled products + * ${elements-var} = {php-method} + * ->productStatus('disabled') + * ->all(); + * ``` + * + * @param string|string[]|null $value The property value + * @return static self reference + * @since 5.5.0 + */ + public function productStatus(array|string|null $value): VariantQuery + { + $this->productStatus = $value; + return $this; + } + /** * Narrows the query results based on the variants’ product types, per their IDs. * @@ -460,6 +506,10 @@ protected function beforePrepare(): bool $this->subQuery->andWhere(['commerce_variants.primaryOwnerId' => $this->productId]); } + if (isset($this->productStatus)) { + $this->_applyProductStatusParam(); + } + if (isset($this->isDefault)) { $this->subQuery->andWhere(Db::parseBooleanParam('isDefault', $this->isDefault, false)); } @@ -689,6 +739,21 @@ protected function beforePrepare(): bool return parent::beforePrepare(); } + protected function afterPrepare(): bool + { + if (!parent::afterPrepare()) { + return false; + } + + // Due to how the element sites table are joined in the subquery we need to do this later in the process + if ($this->productStatus) { + $this->subQuery->leftJoin(CraftTable::ELEMENTS . ' product_elements', '[[product_elements.id]] = [[commerce_variants.primaryOwnerId]]'); + $this->subQuery->leftJoin(CraftTable::ELEMENTS_SITES . ' product_elements_sites', '[[product_elements_sites.elementId]] = [[commerce_variants.primaryOwnerId]] and [[product_elements_sites.siteId]] = [[elements_sites.siteId]]'); + } + + return true; + } + /** * Normalizes the primaryOwnerId param to an array of IDs or null * @@ -737,6 +802,76 @@ private function _applyHasProductParam(): void $this->subQuery->andWhere(['commerce_variants.primaryOwnerId' => $productQuery]); } + /** + * Applies the 'productStatus' param to the query being prepared. + * + * @since 5.5.0 + */ + private function _applyProductStatusParam(): void + { + if (!$this->productStatus) { + return; + } + + // Normalize the product status param + if (!is_array($this->productStatus)) { + $this->productStatus = StringHelper::split($this->productStatus); + } + + $statuses = array_merge($this->productStatus); + + $firstVal = strtolower(reset($statuses)); + if (in_array($firstVal, ['not', 'or'])) { + $glue = $firstVal; + array_shift($statuses); + if (!$statuses) { + return; + } + } else { + $glue = 'or'; + } + + if ($negate = ($glue === 'not')) { + $glue = 'and'; + } + + $condition = [$glue]; + + foreach ($statuses as $status) { + $status = strtolower($status); + + // Logic comes from the `statusCondition()` method in `craft\base\ElementQuery` + // but duplicated here to make editing the table naming more verbose. + $statusCondition = match ($status) { + Element::STATUS_ENABLED => [ + 'product_elements.enabled' => true, + 'product_elements_sites.enabled' => true, + ], + Element::STATUS_DISABLED => [ + 'or', + ['product_elements.enabled' => false], + ['product_elements_sites.enabled' => false], + ], + Element::STATUS_ARCHIVED => ['product_elements.archived' => true], + default => false, + }; + + if ($statusCondition === false) { + throw new QueryAbortedException('Unsupported status: ' . $status); + } + + if ($statusCondition !== null) { + if ($negate) { + $condition[] = ['not', $statusCondition]; + } else { + $condition[] = $statusCondition; + } + } + } + + $this->subQuery->andWhere($condition); + } + /** * @inheritdoc * @since 3.5.0 diff --git a/src/gql/arguments/elements/Variant.php b/src/gql/arguments/elements/Variant.php index 92c1678d66..0a1848e5e0 100644 --- a/src/gql/arguments/elements/Variant.php +++ b/src/gql/arguments/elements/Variant.php @@ -78,6 +78,11 @@ public static function getArguments(): array 'type' => Type::listOf(QueryArgument::getType()), 'description' => 'Narrows the query results based on the variant’s price.', ], + 'productStatus' => [ + 'name' => 'productStatus', + 'type' => Type::listOf(Type::string()), + 'description' => 'Narrows the query results based on the variants’ product’s statuses.', + ], 'promotionalPrice' => [ 'name' => 'promotionalPrice', 'type' => Type::listOf(QueryArgument::getType()), diff --git a/tests/unit/elements/variant/VariantQueryTest.php b/tests/unit/elements/variant/VariantQueryTest.php index a8a2e74dc7..2733bdbccf 100644 --- a/tests/unit/elements/variant/VariantQueryTest.php +++ b/tests/unit/elements/variant/VariantQueryTest.php @@ -8,6 +8,7 @@ namespace unit\elements\variant; use Codeception\Test\Unit; +use craft\base\Element; use craft\commerce\db\Table; use craft\commerce\elements\conditions\purchasables\PurchasableConditionRule; use craft\commerce\elements\db\VariantQuery; @@ -401,4 +402,38 @@ public function orderByDataProvider(): array 'base-price-desc' => ['basePrice DESC', array_reverse(['hct-white', 'hct-blue', 'rad-hood'])], ]; } + + /** + * @param mixed $status + * @param int $expectedCount + * @return void + * @since 5.5.0 + * @dataProvider productStatusDataProvider + */ + public function testProductStatus(mixed $status, int $expectedCount): void + { + $query = Variant::find(); + $query->productStatus($status); + + self::assertCount($expectedCount, $query->all()); + } + + /** + * @return array[] + */ + public function productStatusDataProvider(): array + { + return [ + 'product-enabled' => ['enabled', 3], + 'product-enabled-const' => [Element::STATUS_ENABLED, 3], + 'product-enabled-const-array' => [[Element::STATUS_ENABLED], 3], + 'product-disabled' => ['disabled', 0], + 'product-disabled-const' => [Element::STATUS_DISABLED, 0], + 'product-disabled-const-array' => [[Element::STATUS_DISABLED], 0], + 'product-enabled-disabled' => [['enabled', 'disabled'], 3], + 'product-enabled-disabled-const' => [[Element::STATUS_ENABLED, Element::STATUS_DISABLED], 3], + 'product-not-disabled-array' => [['not', Element::STATUS_DISABLED], 3], + 'product-not-enabled' => [['not', Element::STATUS_ENABLED], 0], + ]; + } } From 126fad241e85804e42035208fc0fefcb05b4a56f Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 13 Nov 2025 12:02:57 +0000 Subject: [PATCH 2/2] Tidy --- src/elements/db/VariantQuery.php | 2 +- tests/unit/elements/variant/VariantQueryTest.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/elements/db/VariantQuery.php b/src/elements/db/VariantQuery.php index 526d57f224..60d7238be3 100755 --- a/src/elements/db/VariantQuery.php +++ b/src/elements/db/VariantQuery.php @@ -98,7 +98,7 @@ class VariantQuery extends PurchasableQuery public mixed $ownerId = null; /** - * @var mixed|null The status the owner product must have. + * @var array|string|null The status the owner product must have. * @used-by productStatus() * @since 5.5.0 */ diff --git a/tests/unit/elements/variant/VariantQueryTest.php b/tests/unit/elements/variant/VariantQueryTest.php index 2733bdbccf..07042be52e 100644 --- a/tests/unit/elements/variant/VariantQueryTest.php +++ b/tests/unit/elements/variant/VariantQueryTest.php @@ -404,7 +404,6 @@ public function orderByDataProvider(): array } /** - * @param mixed $status * @param int $expectedCount * @return void * @since 5.5.0