Skip to content

Commit ce957ae

Browse files
authored
Merge pull request #4179 from craftcms/nathaniel/com-494-5x-graphql-schema-validation-error-with-hasproduct-status
[5.5] `productStatus` variant query param (and GQL)
2 parents 9474631 + 126fad2 commit ce957ae

File tree

4 files changed

+176
-0
lines changed

4 files changed

+176
-0
lines changed

CHANGELOG-WIP.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
- Improved product and variant query performance.
2525
- Improved the performance of retrieving a line item’s catalog pricing rule ID.
2626
- Added the `children`, `parent`, `ancestors` and `descendants` fields to products’ GraphQL data. ([#4122](https://github.com/craftcms/commerce/issues/4122))
27+
- Added the `productStatus` variant query param. ([#4158](https://github.com/craftcms/commerce/issues/4158))
28+
- Added the `productStatus` GraphQL variant query argument. ([#4158](https://github.com/craftcms/commerce/issues/4158))
2729
- Added the `--force` option to the `commerce/reset-data` command. ([#4115](https://github.com/craftcms/commerce/discussions/4115))
2830

2931
### Extensibility

src/elements/db/VariantQuery.php

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
use craft\commerce\Plugin;
1818
use craft\commerce\records\Sale;
1919
use craft\db\Query;
20+
use craft\db\QueryAbortedException;
2021
use craft\db\Table as CraftTable;
2122
use craft\helpers\ArrayHelper;
2223
use craft\helpers\Db;
24+
use craft\helpers\StringHelper;
2325
use DateTime;
2426
use yii\base\InvalidArgumentException;
2527
use yii\base\InvalidConfigException;
@@ -95,6 +97,13 @@ class VariantQuery extends PurchasableQuery
9597
*/
9698
public mixed $ownerId = null;
9799

100+
/**
101+
* @var array|string|null The status the owner product must have.
102+
* @used-by productStatus()
103+
* @since 5.5.0
104+
*/
105+
public array|string|null $productStatus = null;
106+
98107
/**
99108
* @var mixed
100109
*/
@@ -257,6 +266,43 @@ public function ownerId(mixed $value): VariantQuery
257266
return $this;
258267
}
259268

269+
/**
270+
* Narrows the query results based on the {elements}’ product’s statuses.
271+
*
272+
* Possible values include:
273+
*
274+
* | Value | Fetches {elements}…
275+
* | - | -
276+
* | `'enabled'` _(default)_ | that are enabled.
277+
* | `'disabled'` | that are disabled.
278+
* | `['not', 'disabled']` | that are not disabled.
279+
*
280+
* ---
281+
*
282+
* ```twig
283+
* {# Fetch {elements} with disabled products #}
284+
* {% set {elements-var} = {twig-method}
285+
* .productStatus('disabled')
286+
* .all() %}
287+
* ```
288+
*
289+
* ```php
290+
* // Fetch {elements} with disabled products
291+
* ${elements-var} = {php-method}
292+
* ->productStatus('disabled')
293+
* ->all();
294+
* ```
295+
*
296+
* @param string|string[]|null $value The property value
297+
* @return static self reference
298+
* @since 5.5.0
299+
*/
300+
public function productStatus(array|string|null $value): VariantQuery
301+
{
302+
$this->productStatus = $value;
303+
return $this;
304+
}
305+
260306
/**
261307
* Narrows the query results based on the variants’ product types, per their IDs.
262308
*
@@ -460,6 +506,10 @@ protected function beforePrepare(): bool
460506
$this->subQuery->andWhere(['commerce_variants.primaryOwnerId' => $this->productId]);
461507
}
462508

509+
if (isset($this->productStatus)) {
510+
$this->_applyProductStatusParam();
511+
}
512+
463513
if (isset($this->isDefault)) {
464514
$this->subQuery->andWhere(Db::parseBooleanParam('isDefault', $this->isDefault, false));
465515
}
@@ -689,6 +739,21 @@ protected function beforePrepare(): bool
689739
return parent::beforePrepare();
690740
}
691741

742+
protected function afterPrepare(): bool
743+
{
744+
if (!parent::afterPrepare()) {
745+
return false;
746+
}
747+
748+
// Due to how the element sites table are joined in the subquery we need to do this later in the process
749+
if ($this->productStatus) {
750+
$this->subQuery->leftJoin(CraftTable::ELEMENTS . ' product_elements', '[[product_elements.id]] = [[commerce_variants.primaryOwnerId]]');
751+
$this->subQuery->leftJoin(CraftTable::ELEMENTS_SITES . ' product_elements_sites', '[[product_elements_sites.elementId]] = [[commerce_variants.primaryOwnerId]] and [[product_elements_sites.siteId]] = [[elements_sites.siteId]]');
752+
}
753+
754+
return true;
755+
}
756+
692757
/**
693758
* Normalizes the primaryOwnerId param to an array of IDs or null
694759
*
@@ -737,6 +802,76 @@ private function _applyHasProductParam(): void
737802
$this->subQuery->andWhere(['commerce_variants.primaryOwnerId' => $productQuery]);
738803
}
739804

805+
/**
806+
* Applies the 'productStatus' param to the query being prepared.
807+
*
808+
* @since 5.5.0
809+
*/
810+
private function _applyProductStatusParam(): void
811+
{
812+
if (!$this->productStatus) {
813+
return;
814+
}
815+
816+
// Normalize the product status param
817+
if (!is_array($this->productStatus)) {
818+
$this->productStatus = StringHelper::split($this->productStatus);
819+
}
820+
821+
$statuses = array_merge($this->productStatus);
822+
823+
$firstVal = strtolower(reset($statuses));
824+
if (in_array($firstVal, ['not', 'or'])) {
825+
$glue = $firstVal;
826+
array_shift($statuses);
827+
if (!$statuses) {
828+
return;
829+
}
830+
} else {
831+
$glue = 'or';
832+
}
833+
834+
if ($negate = ($glue === 'not')) {
835+
$glue = 'and';
836+
}
837+
838+
$condition = [$glue];
839+
840+
foreach ($statuses as $status) {
841+
$status = strtolower($status);
842+
843+
// Logic comes from the `statusCondition()` method in `craft\base\ElementQuery`
844+
// but duplicated here to make editing the table naming more verbose.
845+
$statusCondition = match ($status) {
846+
Element::STATUS_ENABLED => [
847+
'product_elements.enabled' => true,
848+
'product_elements_sites.enabled' => true,
849+
],
850+
Element::STATUS_DISABLED => [
851+
'or',
852+
['product_elements.enabled' => false],
853+
['product_elements_sites.enabled' => false],
854+
],
855+
Element::STATUS_ARCHIVED => ['product_elements.archived' => true],
856+
default => false,
857+
};
858+
859+
if ($statusCondition === false) {
860+
throw new QueryAbortedException('Unsupported status: ' . $status);
861+
}
862+
863+
if ($statusCondition !== null) {
864+
if ($negate) {
865+
$condition[] = ['not', $statusCondition];
866+
} else {
867+
$condition[] = $statusCondition;
868+
}
869+
}
870+
}
871+
872+
$this->subQuery->andWhere($condition);
873+
}
874+
740875
/**
741876
* @inheritdoc
742877
* @since 3.5.0

src/gql/arguments/elements/Variant.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ public static function getArguments(): array
7878
'type' => Type::listOf(QueryArgument::getType()),
7979
'description' => 'Narrows the query results based on the variant’s price.',
8080
],
81+
'productStatus' => [
82+
'name' => 'productStatus',
83+
'type' => Type::listOf(Type::string()),
84+
'description' => 'Narrows the query results based on the variants’ product’s statuses.',
85+
],
8186
'promotionalPrice' => [
8287
'name' => 'promotionalPrice',
8388
'type' => Type::listOf(QueryArgument::getType()),

tests/unit/elements/variant/VariantQueryTest.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
namespace unit\elements\variant;
99

1010
use Codeception\Test\Unit;
11+
use craft\base\Element;
1112
use craft\commerce\db\Table;
1213
use craft\commerce\elements\conditions\purchasables\PurchasableConditionRule;
1314
use craft\commerce\elements\db\VariantQuery;
@@ -401,4 +402,37 @@ public function orderByDataProvider(): array
401402
'base-price-desc' => ['basePrice DESC', array_reverse(['hct-white', 'hct-blue', 'rad-hood'])],
402403
];
403404
}
405+
406+
/**
407+
* @param int $expectedCount
408+
* @return void
409+
* @since 5.5.0
410+
* @dataProvider productStatusDataProvider
411+
*/
412+
public function testProductStatus(mixed $status, int $expectedCount): void
413+
{
414+
$query = Variant::find();
415+
$query->productStatus($status);
416+
417+
self::assertCount($expectedCount, $query->all());
418+
}
419+
420+
/**
421+
* @return array[]
422+
*/
423+
public function productStatusDataProvider(): array
424+
{
425+
return [
426+
'product-enabled' => ['enabled', 3],
427+
'product-enabled-const' => [Element::STATUS_ENABLED, 3],
428+
'product-enabled-const-array' => [[Element::STATUS_ENABLED], 3],
429+
'product-disabled' => ['disabled', 0],
430+
'product-disabled-const' => [Element::STATUS_DISABLED, 0],
431+
'product-disabled-const-array' => [[Element::STATUS_DISABLED], 0],
432+
'product-enabled-disabled' => [['enabled', 'disabled'], 3],
433+
'product-enabled-disabled-const' => [[Element::STATUS_ENABLED, Element::STATUS_DISABLED], 3],
434+
'product-not-disabled-array' => [['not', Element::STATUS_DISABLED], 3],
435+
'product-not-enabled' => [['not', Element::STATUS_ENABLED], 0],
436+
];
437+
}
404438
}

0 commit comments

Comments
 (0)