From c5fecbdbf8a3963d3a2f1955511af99dfd3ffd9a Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 5 Mar 2025 11:50:44 +0000 Subject: [PATCH 01/32] WIP Craft GQL --- src/Plugin.php | 52 +++++++++++++++++ src/elements/Product.php | 18 ++++++ src/gql/arguments/elements/Product.php | 55 ++++++++++++++++++ src/gql/interfaces/elements/Product.php | 74 ++++++++++++++++++++++++ src/gql/queries/Product.php | 57 ++++++++++++++++++ src/gql/resolvers/elements/Product.php | 61 +++++++++++++++++++ src/gql/types/elements/Product.php | 32 ++++++++++ src/gql/types/generators/ProductType.php | 53 +++++++++++++++++ src/translations/en/shopify.php | 1 + 9 files changed, 403 insertions(+) create mode 100644 src/gql/arguments/elements/Product.php create mode 100644 src/gql/interfaces/elements/Product.php create mode 100644 src/gql/queries/Product.php create mode 100644 src/gql/resolvers/elements/Product.php create mode 100644 src/gql/types/elements/Product.php create mode 100644 src/gql/types/generators/ProductType.php diff --git a/src/Plugin.php b/src/Plugin.php index 026d755..87ea185 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -19,6 +19,9 @@ use craft\db\Query; use craft\events\DefineConsoleActionsEvent; use craft\events\RegisterComponentTypesEvent; +use craft\events\RegisterGqlQueriesEvent; +use craft\events\RegisterGqlSchemaComponentsEvent; +use craft\events\RegisterGqlTypesEvent; use craft\events\RegisterUrlRulesEvent; use craft\feedme\events\RegisterFeedMeFieldsEvent; use craft\fields\Link; @@ -28,11 +31,14 @@ use craft\services\Elements; use craft\services\Fields; use craft\services\Gc; +use craft\services\Gql; use craft\services\Utilities; use craft\shopify\db\Table; use craft\shopify\elements\Product; use craft\shopify\feedme\fields\Products as FeedMeProductsField; use craft\shopify\fields\Products as ProductsField; +use craft\shopify\gql\interfaces\elements\Product as GqlProductInterface; +use craft\shopify\gql\queries\Product as GqlProductQueries; use craft\shopify\handlers\Webhook; use craft\shopify\linktypes\Product as ProductLinkType; use craft\shopify\models\Settings; @@ -130,6 +136,9 @@ public function init() $this->_registerResaveCommands(); $this->_registerGarbageCollection(); $this->_registerFeedMeEvents(); + $this->_registerGqlInterfaces(); + $this->_registerGqlQueries(); + $this->_registerGqlComponents(); if (!$request->getIsConsoleRequest()) { if ($request->getIsCpRequest()) { @@ -244,6 +253,49 @@ private function _registerElementTypes(): void }); } + + /** + * Register the Gql interfaces + * @since 6.1.0 + */ + private function _registerGqlInterfaces(): void + { + Event::on(Gql::class, Gql::EVENT_REGISTER_GQL_TYPES, static function(RegisterGqlTypesEvent $event) { + $event->types[] = GqlProductInterface::class; + }); + } + + /** + * Register the Gql queries + * @since 6.1.0 + */ + private function _registerGqlQueries(): void + { + Event::on(Gql::class, Gql::EVENT_REGISTER_GQL_QUERIES, static function(RegisterGqlQueriesEvent $event) { + $event->queries = array_merge( + $event->queries, + GqlProductQueries::getQueries(), + ); + }); + } + + /** + * Register the Gql permissions + * @since 6.1.0 + */ + private function _registerGqlComponents(): void + { + Event::on(Gql::class, Gql::EVENT_REGISTER_GQL_SCHEMA_COMPONENTS, static function(RegisterGqlSchemaComponentsEvent $event) { + $typeName = (new Product())->getGqlTypeName(); + $event->queries = array_merge($event->queries, [ + Craft::t('shopify', 'Shopify Products') => [ + $typeName . ':read' => ['label' => Craft::t('shopify', 'View products')], + ] + ]); + }); + } + + /** * Register Shopify’s fields * diff --git a/src/elements/Product.php b/src/elements/Product.php index efdd9a6..a84d662 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -213,6 +213,24 @@ public function init(): void parent::init(); } + /** + * @inheritdoc + * @since 3.0 + */ + public static function gqlScopesByContext(mixed $context): array + { + /** @var FieldLayout $context */ + return ['ShopifyProduct']; + } + + /** + * @return string + */ + public function getGqlTypeName(): string + { + return 'ShopifyProduct'; + } + /** * @inheritdoc */ diff --git a/src/gql/arguments/elements/Product.php b/src/gql/arguments/elements/Product.php new file mode 100644 index 0000000..8c1998a --- /dev/null +++ b/src/gql/arguments/elements/Product.php @@ -0,0 +1,55 @@ + + * @since 6.1.0 + */ +class Product extends ElementArguments +{ + /** + * @inheritdoc + */ + public static function getArguments(): array + { + return array_merge(parent::getArguments(), self::getContentArguments(), [ + 'shopifyId' => [ + 'name' => 'shopifyId', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the Shopify ID on the product.', + ], + 'shopifyGid' => [ + 'name' => 'shopifyGid', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the Shopify GID on the product.', + ], + ]); + } + + /** + * @inheritdoc + */ + public static function getContentArguments(): array + { + $productFieldsArguments = Craft::$app->getGql()->getContentArguments([ + Plugin::getInstance()->getSettings()->getProductFieldLayout(), + ], ProductElement::class); + + return array_merge(parent::getContentArguments(), $productFieldsArguments); + } +} diff --git a/src/gql/interfaces/elements/Product.php b/src/gql/interfaces/elements/Product.php new file mode 100644 index 0000000..963b110 --- /dev/null +++ b/src/gql/interfaces/elements/Product.php @@ -0,0 +1,74 @@ + + * @since 6.1.0 + */ +class Product extends Element +{ + /** + * @inheritdoc + */ + public static function getTypeGenerator(): string + { + return ProductType::class; + } + + /** + * @inheritdoc + */ + public static function getType($fields = null): Type + { + if ($type = GqlEntityRegistry::getEntity(self::getName())) { + return $type; + } + + $type = GqlEntityRegistry::createEntity(self::getName(), new InterfaceType([ + 'name' => static::getName(), + 'fields' => self::class . '::getFieldDefinitions', + 'description' => 'This is the interface implemented by all products.', + 'resolveType' => function(ProductElement $value) { + return $value->getGqlTypeName(); + }, + ])); + + ProductType::generateTypes(); + + return $type; + } + + /** + * @inheritdoc + */ + public static function getName(): string + { + return 'ShopifyProductInterface'; + } + + /** + * @inheritdoc + */ + public static function getFieldDefinitions(): array + { + return Craft::$app->getGql()->prepareFieldDefinitions(array_merge(parent::getFieldDefinitions(), [ + + ]), self::getName()); + } +} diff --git a/src/gql/queries/Product.php b/src/gql/queries/Product.php new file mode 100644 index 0000000..6c549c2 --- /dev/null +++ b/src/gql/queries/Product.php @@ -0,0 +1,57 @@ + + * @since 6.1.0 + */ +class Product extends Query +{ + /** + * @inheritdoc + */ + public static function getQueries(bool $checkToken = true): array + { + $entities = Gql::extractAllowedEntitiesFromSchema(); + $typeName = (new \craft\shopify\elements\Product())->getGqlTypeName(); + if ($checkToken && !isset($entities[$typeName])) { + return []; + } + + return [ + 'shopifyProducts' => [ + 'type' => Type::listOf(ProductInterface::getType()), + 'args' => ProductArguments::getArguments(), + 'resolve' => ProductResolver::class . '::resolve', + 'description' => 'This query is used to query for products.', + ], + 'shopifyProductCount' => [ + 'type' => Type::nonNull(Type::int()), + 'args' => ProductArguments::getArguments(), + 'resolve' => ProductResolver::class . '::resolveCount', + 'description' => 'This query is used to return the number of products.', + ], + 'shopifyProduct' => [ + 'type' => ProductInterface::getType(), + 'args' => ProductArguments::getArguments(), + 'resolve' => ProductResolver::class . '::resolveOne', + 'description' => 'This query is used to query for a product.', + ], + ]; + } +} diff --git a/src/gql/resolvers/elements/Product.php b/src/gql/resolvers/elements/Product.php new file mode 100644 index 0000000..3834026 --- /dev/null +++ b/src/gql/resolvers/elements/Product.php @@ -0,0 +1,61 @@ + + * @since 6.1.0 + */ +class Product extends ElementResolver +{ + /** + * @inheritdoc + */ + public static function prepareQuery(mixed $source, array $arguments, $fieldName = null): mixed + { + // If this is the beginning of a resolver chain, start fresh + if ($source === null) { + $query = ProductElement::find(); + // If not, get the prepared element query + } else { + $query = $source->$fieldName; + } + + // If it's preloaded, it's preloaded. + if (!$query instanceof ElementQuery) { + return $query; + } + + foreach ($arguments as $key => $value) { + if (method_exists($query, $key)) { + $query->$key($value); + } elseif (property_exists($query, $key)) { + $query->$key = $value; + } else { + // Catch custom field queries + $query->$key($value); + } + } + + $pairs = GqlHelper::extractAllowedEntitiesFromSchema(); + $typeName = (new ProductElement())->getGqlTypeName(); + + if (!isset($pairs[$typeName])) { + return []; + } + + return $query; + } +} diff --git a/src/gql/types/elements/Product.php b/src/gql/types/elements/Product.php new file mode 100644 index 0000000..8cda7d4 --- /dev/null +++ b/src/gql/types/elements/Product.php @@ -0,0 +1,32 @@ + + * @since 6.1.0 + */ +class Product extends ElementType +{ + /** + * @inheritdoc + */ + public function __construct(array $config) + { + $config['interfaces'] = [ + ProductInterface::getType(), + ]; + + parent::__construct($config); + } +} diff --git a/src/gql/types/generators/ProductType.php b/src/gql/types/generators/ProductType.php new file mode 100644 index 0000000..4271dc6 --- /dev/null +++ b/src/gql/types/generators/ProductType.php @@ -0,0 +1,53 @@ + + * @since 6.1.0 + */ +class ProductType implements GeneratorInterface +{ + /** + * @inheritdoc + */ + public static function generateTypes(mixed $context = null): array + { + $productFieldLayout = Plugin::getInstance()->getSettings()->getProductFieldLayout(); + + $typeName = (new ProductElement())->getGqlTypeName(); + $contentFields = $productFieldLayout->getCustomFields(); + $contentFieldGqlTypes = []; + + /** @var Field $contentField */ + foreach ($contentFields as $contentField) { + $contentFieldGqlTypes[$contentField->handle] = $contentField->getContentGqlType(); + } + + $productFields = Craft::$app->getGql()->prepareFieldDefinitions(array_merge(ProductInterface::getFieldDefinitions(), $contentFieldGqlTypes), $typeName); + return [ + $typeName => GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new ProductTypeElement([ + 'name' => $typeName, + 'fields' => function() use ($productFields) { + return $productFields; + }, + ])) + ]; + } +} diff --git a/src/translations/en/shopify.php b/src/translations/en/shopify.php index 2acd171..c957c20 100644 --- a/src/translations/en/shopify.php +++ b/src/translations/en/shopify.php @@ -82,6 +82,7 @@ 'Updating product variants for “{title}”' => 'Updating product variants for “{title}”', 'Variants' => 'Variants', 'Vendor' => 'Vendor', + 'View products' => 'View products', 'Webhook deleted' => 'Webhook deleted', 'Webhooks could not be deleted' => 'Webhooks could not be deleted', 'Webhooks could not be registered.' => 'Webhooks could not be registered.', From 05abd1413c8573499f2c6d937597bf170058b185 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 5 Mar 2025 11:51:34 +0000 Subject: [PATCH 02/32] fix cs --- src/Plugin.php | 2 +- src/gql/arguments/elements/Product.php | 4 ++-- src/gql/interfaces/elements/Product.php | 4 ++-- src/gql/queries/Product.php | 2 +- src/gql/resolvers/elements/Product.php | 4 ++-- src/gql/types/elements/Product.php | 2 +- src/gql/types/generators/ProductType.php | 6 +++--- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Plugin.php b/src/Plugin.php index 87ea185..72b59c1 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -290,7 +290,7 @@ private function _registerGqlComponents(): void $event->queries = array_merge($event->queries, [ Craft::t('shopify', 'Shopify Products') => [ $typeName . ':read' => ['label' => Craft::t('shopify', 'View products')], - ] + ], ]); }); } diff --git a/src/gql/arguments/elements/Product.php b/src/gql/arguments/elements/Product.php index 8c1998a..cd59b7a 100644 --- a/src/gql/arguments/elements/Product.php +++ b/src/gql/arguments/elements/Product.php @@ -8,10 +8,10 @@ namespace craft\shopify\gql\arguments\elements; use Craft; -use craft\shopify\elements\Product as ProductElement; -use craft\shopify\Plugin; use craft\gql\base\ElementArguments; use craft\gql\types\QueryArgument; +use craft\shopify\elements\Product as ProductElement; +use craft\shopify\Plugin; use GraphQL\Type\Definition\Type; /** diff --git a/src/gql/interfaces/elements/Product.php b/src/gql/interfaces/elements/Product.php index 963b110..9cdee2b 100644 --- a/src/gql/interfaces/elements/Product.php +++ b/src/gql/interfaces/elements/Product.php @@ -8,10 +8,10 @@ namespace craft\shopify\gql\interfaces\elements; use Craft; -use craft\shopify\elements\Product as ProductElement; -use craft\shopify\gql\types\generators\ProductType; use craft\gql\GqlEntityRegistry; use craft\gql\interfaces\Element; +use craft\shopify\elements\Product as ProductElement; +use craft\shopify\gql\types\generators\ProductType; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\Type; diff --git a/src/gql/queries/Product.php b/src/gql/queries/Product.php index 6c549c2..f7e0411 100644 --- a/src/gql/queries/Product.php +++ b/src/gql/queries/Product.php @@ -7,11 +7,11 @@ namespace craft\shopify\gql\queries; +use craft\gql\base\Query; use craft\helpers\Gql; use craft\shopify\gql\arguments\elements\Product as ProductArguments; use craft\shopify\gql\interfaces\elements\Product as ProductInterface; use craft\shopify\gql\resolvers\elements\Product as ProductResolver; -use craft\gql\base\Query; use GraphQL\Type\Definition\Type; /** diff --git a/src/gql/resolvers/elements/Product.php b/src/gql/resolvers/elements/Product.php index 3834026..b7e25c3 100644 --- a/src/gql/resolvers/elements/Product.php +++ b/src/gql/resolvers/elements/Product.php @@ -7,10 +7,10 @@ namespace craft\shopify\gql\resolvers\elements; -use craft\shopify\elements\Product as ProductElement; -use craft\helpers\Gql as GqlHelper; use craft\elements\db\ElementQuery; use craft\gql\base\ElementResolver; +use craft\helpers\Gql as GqlHelper; +use craft\shopify\elements\Product as ProductElement; /** * Class Product diff --git a/src/gql/types/elements/Product.php b/src/gql/types/elements/Product.php index 8cda7d4..1204c51 100644 --- a/src/gql/types/elements/Product.php +++ b/src/gql/types/elements/Product.php @@ -7,8 +7,8 @@ namespace craft\shopify\gql\types\elements; -use craft\shopify\gql\interfaces\elements\Product as ProductInterface; use craft\gql\types\elements\Element as ElementType; +use craft\shopify\gql\interfaces\elements\Product as ProductInterface; /** * Class Product diff --git a/src/gql/types/generators/ProductType.php b/src/gql/types/generators/ProductType.php index 4271dc6..eefd8e3 100644 --- a/src/gql/types/generators/ProductType.php +++ b/src/gql/types/generators/ProductType.php @@ -9,11 +9,11 @@ use Craft; use craft\base\Field; +use craft\gql\base\GeneratorInterface; +use craft\gql\GqlEntityRegistry; use craft\shopify\elements\Product as ProductElement; use craft\shopify\gql\interfaces\elements\Product as ProductInterface; use craft\shopify\gql\types\elements\Product as ProductTypeElement; -use craft\gql\base\GeneratorInterface; -use craft\gql\GqlEntityRegistry; use craft\shopify\Plugin; /** @@ -47,7 +47,7 @@ public static function generateTypes(mixed $context = null): array 'fields' => function() use ($productFields) { return $productFields; }, - ])) + ])), ]; } } From c37d9f29f1d56517edaea1180baa646618305edd Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 5 Mar 2025 12:10:52 +0000 Subject: [PATCH 03/32] Add extra gql product arguments --- src/elements/db/ProductQuery.php | 1 - src/gql/arguments/elements/Product.php | 30 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/elements/db/ProductQuery.php b/src/elements/db/ProductQuery.php index afa18c0..c5ddafe 100644 --- a/src/elements/db/ProductQuery.php +++ b/src/elements/db/ProductQuery.php @@ -46,7 +46,6 @@ class ProductQuery extends ElementQuery */ public mixed $handle = null; - /** * @var mixed|null */ diff --git a/src/gql/arguments/elements/Product.php b/src/gql/arguments/elements/Product.php index cd59b7a..c5a7bf6 100644 --- a/src/gql/arguments/elements/Product.php +++ b/src/gql/arguments/elements/Product.php @@ -38,6 +38,36 @@ public static function getArguments(): array 'type' => Type::listOf(QueryArgument::getType()), 'description' => 'Narrows the query results based on the Shopify GID on the product.', ], + 'shopifyStatus' => [ + 'name' => 'shopifyStatus', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the Shopify status of the product.', + ], + 'handle' => [ + 'name' => 'handle', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the handle on the product.', + ], + 'productType' => [ + 'name' => 'productType', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the product type on the product.', + ], + 'tags' => [ + 'name' => 'tags', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the tags on the product.', + ], + 'vendor' => [ + 'name' => 'vendor', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the vendor on the product.', + ], + 'publishedOnCurrentPublication' => [ + 'name' => 'publishedOnCurrentPublication', + 'type' => Type::boolean(), + 'description' => 'Narrows the query results based on the published on current publication on the product.', + ], ]); } From ebe0de39c6c82df9fdf9043d6556fb3b495a21b2 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Mon, 10 Mar 2025 08:05:08 +0000 Subject: [PATCH 04/32] Make sure to eagleload non-elements in product queries --- src/gql/resolvers/elements/Product.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gql/resolvers/elements/Product.php b/src/gql/resolvers/elements/Product.php index b7e25c3..29a5fd2 100644 --- a/src/gql/resolvers/elements/Product.php +++ b/src/gql/resolvers/elements/Product.php @@ -56,6 +56,8 @@ public static function prepareQuery(mixed $source, array $arguments, $fieldName return []; } + $query->withAll(); + return $query; } } From b8aee2b3dd9d247308e75fc6689ff5e89037790d Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Mon, 10 Mar 2025 08:05:50 +0000 Subject: [PATCH 05/32] Add variants to product gql fields --- src/gql/interfaces/elements/Product.php | 7 ++- src/gql/types/VariantType.php | 72 +++++++++++++++++++++++++ src/gql/types/elements/Product.php | 11 ++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/gql/types/VariantType.php diff --git a/src/gql/interfaces/elements/Product.php b/src/gql/interfaces/elements/Product.php index 9cdee2b..5b24d5c 100644 --- a/src/gql/interfaces/elements/Product.php +++ b/src/gql/interfaces/elements/Product.php @@ -12,6 +12,7 @@ use craft\gql\interfaces\Element; use craft\shopify\elements\Product as ProductElement; use craft\shopify\gql\types\generators\ProductType; +use craft\shopify\gql\types\VariantType; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\Type; @@ -68,7 +69,11 @@ public static function getName(): string public static function getFieldDefinitions(): array { return Craft::$app->getGql()->prepareFieldDefinitions(array_merge(parent::getFieldDefinitions(), [ - + 'variants' => [ + 'name' => 'variants', + 'type' => VariantType::getType(), + 'description' => 'The product’s variants.', + ], ]), self::getName()); } } diff --git a/src/gql/types/VariantType.php b/src/gql/types/VariantType.php new file mode 100644 index 0000000..10ddc32 --- /dev/null +++ b/src/gql/types/VariantType.php @@ -0,0 +1,72 @@ + + * @since 6.1.0 + */ +class VariantType extends ScalarType implements SingularTypeInterface +{ + /** + * @return string + */ + public static function getName(): string + { + return 'ShopifyVariant'; + } + + public static function getType(): Type + { + return GqlEntityRegistry::getOrCreate(static::getName(), fn() => new self()); + } + + public function serialize($value) + { + if (empty($value)) { + return null; + } + + return $value; + } + + public function parseValue($value) + { + if (!is_string($value) && !is_array($value) && !is_null($value)) { + throw new GqlException('Variants must be either a string, array, or null.'); + } + + return $value; + } + + public function parseLiteral(Node $valueNode, ?array $variables = null) + { + if ($valueNode instanceof StringValueNode) { + return Json::decodeIfJson($valueNode->value); + } + + if ($valueNode instanceof NullValueNode) { + return null; + } + + // This message will be lost by the wrapping exception, but it feels good to provide one. + throw new GqlException("Variants must be either an array, string or null."); + } +} diff --git a/src/gql/types/elements/Product.php b/src/gql/types/elements/Product.php index 1204c51..02db353 100644 --- a/src/gql/types/elements/Product.php +++ b/src/gql/types/elements/Product.php @@ -9,6 +9,7 @@ use craft\gql\types\elements\Element as ElementType; use craft\shopify\gql\interfaces\elements\Product as ProductInterface; +use GraphQL\Type\Definition\ResolveInfo; /** * Class Product @@ -29,4 +30,14 @@ public function __construct(array $config) parent::__construct($config); } + + protected function resolve(mixed $source, array $arguments, mixed $context, ResolveInfo $resolveInfo): mixed + { + $fieldName = $resolveInfo->fieldName; + return match ($fieldName) { + // @TODO remove this when the conflict in (https://github.com/craftcms/cms/blob/7b889521442ef68be39edf52b55f4747da722e94/src/gql/ElementQueryConditionBuilder.php#L290-L321) is resolved + 'variants' => $source->getVariants(), + default => parent::resolve($source, $arguments, $context, $resolveInfo), + }; + } } From 9a8321b152a9d1c2a781652ba86bcf6083809640 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 11 Mar 2025 09:33:31 +0000 Subject: [PATCH 06/32] Add more GQL product field definitions --- src/gql/interfaces/elements/Product.php | 56 +++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/gql/interfaces/elements/Product.php b/src/gql/interfaces/elements/Product.php index 5b24d5c..a806412 100644 --- a/src/gql/interfaces/elements/Product.php +++ b/src/gql/interfaces/elements/Product.php @@ -10,6 +10,7 @@ use Craft; use craft\gql\GqlEntityRegistry; use craft\gql\interfaces\Element; +use craft\gql\types\DateTime; use craft\shopify\elements\Product as ProductElement; use craft\shopify\gql\types\generators\ProductType; use craft\shopify\gql\types\VariantType; @@ -69,6 +70,61 @@ public static function getName(): string public static function getFieldDefinitions(): array { return Craft::$app->getGql()->prepareFieldDefinitions(array_merge(parent::getFieldDefinitions(), [ + 'createdAt' => [ + 'name' => 'createdAt', + 'type' => DateTime::getType(), + 'description' => 'The date the product was created in Shopify.', + ], + 'publishedAt' => [ + 'name' => 'publishedAt', + 'type' => DateTime::getType(), + 'description' => 'The date the product was published in Shopify.', + ], + 'updatedAt' => [ + 'name' => 'updatedAt', + 'type' => DateTime::getType(), + 'description' => 'The date the product was updated in Shopify.', + ], + 'handle' => [ + 'name' => 'handle', + 'type' => Type::string(), + 'description' => 'The product’s handle.', + ], + 'descriptionHtml' => [ + 'name' => 'descriptionHtml', + 'type' => Type::string(), + 'description' => 'The product’s description HTML in Shopify.', + ], + 'productType' => [ + 'name' => 'productType', + 'type' => Type::string(), + 'description' => 'The product’s type in Shopify.', + ], + 'publishedOnCurrentPublication' => [ + 'name' => 'publishedOnCurrentPublication', + 'type' => Type::boolean(), + 'description' => 'If the product is published on the current publication in Shopify.', + ], + 'shopifyId' => [ + 'name' => 'shopifyId', + 'type' => Type::string(), + 'description' => 'The product’s Shopify ID.', + ], + 'shopifyGid' => [ + 'name' => 'shopifyGid', + 'type' => Type::string(), + 'description' => 'The product’s Shopify GID.', + ], + 'shopifyStatus' => [ + 'name' => 'shopifyStatus', + 'type' => Type::string(), + 'description' => 'The product’s status in Shopify.', + ], + 'vendor' => [ + 'name' => 'vendor', + 'type' => Type::string(), + 'description' => 'The product’s vendor in Shopify.', + ], 'variants' => [ 'name' => 'variants', 'type' => VariantType::getType(), From 729632b12aecf5cabe3720a3407d1e6d1ccd319f Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 11 Mar 2025 09:39:01 +0000 Subject: [PATCH 07/32] Favour a more generic type that is resuable --- src/gql/interfaces/elements/Product.php | 19 ++++++- src/gql/types/JsonType.php | 72 +++++++++++++++++++++++++ src/gql/types/VariantType.php | 2 +- 3 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 src/gql/types/JsonType.php diff --git a/src/gql/interfaces/elements/Product.php b/src/gql/interfaces/elements/Product.php index a806412..7b85b9d 100644 --- a/src/gql/interfaces/elements/Product.php +++ b/src/gql/interfaces/elements/Product.php @@ -13,7 +13,7 @@ use craft\gql\types\DateTime; use craft\shopify\elements\Product as ProductElement; use craft\shopify\gql\types\generators\ProductType; -use craft\shopify\gql\types\VariantType; +use craft\shopify\gql\types\JsonType; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\Type; @@ -125,9 +125,24 @@ public static function getFieldDefinitions(): array 'type' => Type::string(), 'description' => 'The product’s vendor in Shopify.', ], + 'images' => [ + 'name' => 'images', + 'type' => JsonType::getType(), + 'description' => 'The product’s images in Shopify.', + ], + 'tags' => [ + 'name' => 'tags', + 'type' => JsonType::getType(), + 'description' => 'The product’s tags in Shopify.', + ], + 'metafields' => [ + 'name' => 'metafields', + 'type' => JsonType::getType(), + 'description' => 'The product’s metafields in Shopify.', + ], 'variants' => [ 'name' => 'variants', - 'type' => VariantType::getType(), + 'type' => JsonType::getType(), 'description' => 'The product’s variants.', ], ]), self::getName()); diff --git a/src/gql/types/JsonType.php b/src/gql/types/JsonType.php new file mode 100644 index 0000000..2720e51 --- /dev/null +++ b/src/gql/types/JsonType.php @@ -0,0 +1,72 @@ + + * @since 6.1.0 + */ +class JsonType extends ScalarType implements SingularTypeInterface +{ + /** + * @return string + */ + public static function getName(): string + { + return 'ShopifyJson'; + } + + public static function getType(): Type + { + return GqlEntityRegistry::getOrCreate(static::getName(), fn() => new self()); + } + + public function serialize($value) + { + if (empty($value)) { + return null; + } + + return $value; + } + + public function parseValue($value) + { + if (!is_string($value) && !is_array($value) && !is_null($value)) { + throw new GqlException('Data must be either a string, array, or null.'); + } + + return $value; + } + + public function parseLiteral(Node $valueNode, ?array $variables = null) + { + if ($valueNode instanceof StringValueNode) { + return Json::decodeIfJson($valueNode->value); + } + + if ($valueNode instanceof NullValueNode) { + return null; + } + + // This message will be lost by the wrapping exception, but it feels good to provide one. + throw new GqlException("Data must be either an array, string or null."); + } +} diff --git a/src/gql/types/VariantType.php b/src/gql/types/VariantType.php index 10ddc32..2aeacd0 100644 --- a/src/gql/types/VariantType.php +++ b/src/gql/types/VariantType.php @@ -18,7 +18,7 @@ use GraphQL\Type\Definition\Type; /** - * Class SaleType + * Class VariantType * * @author Pixel & Tonic, Inc. * @since 6.1.0 From 54426f8762af2993c462ab96ba0d2080654aae99 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 11 Mar 2025 09:40:06 +0000 Subject: [PATCH 08/32] Allow access to the full data --- src/gql/interfaces/elements/Product.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/gql/interfaces/elements/Product.php b/src/gql/interfaces/elements/Product.php index 7b85b9d..1fd14fb 100644 --- a/src/gql/interfaces/elements/Product.php +++ b/src/gql/interfaces/elements/Product.php @@ -143,7 +143,12 @@ public static function getFieldDefinitions(): array 'variants' => [ 'name' => 'variants', 'type' => JsonType::getType(), - 'description' => 'The product’s variants.', + 'description' => 'The product’s variants in Shopify.', + ], + 'data' => [ + 'name' => 'data', + 'type' => JsonType::getType(), + 'description' => 'The product’s synced data from Shopify.', ], ]), self::getName()); } From 0c7d7eda96e7d0159b81510be1dbe27850a06346 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 11 Mar 2025 09:40:30 +0000 Subject: [PATCH 09/32] `VariantType` is no longer in use --- src/gql/types/VariantType.php | 72 ----------------------------------- 1 file changed, 72 deletions(-) delete mode 100644 src/gql/types/VariantType.php diff --git a/src/gql/types/VariantType.php b/src/gql/types/VariantType.php deleted file mode 100644 index 2aeacd0..0000000 --- a/src/gql/types/VariantType.php +++ /dev/null @@ -1,72 +0,0 @@ - - * @since 6.1.0 - */ -class VariantType extends ScalarType implements SingularTypeInterface -{ - /** - * @return string - */ - public static function getName(): string - { - return 'ShopifyVariant'; - } - - public static function getType(): Type - { - return GqlEntityRegistry::getOrCreate(static::getName(), fn() => new self()); - } - - public function serialize($value) - { - if (empty($value)) { - return null; - } - - return $value; - } - - public function parseValue($value) - { - if (!is_string($value) && !is_array($value) && !is_null($value)) { - throw new GqlException('Variants must be either a string, array, or null.'); - } - - return $value; - } - - public function parseLiteral(Node $valueNode, ?array $variables = null) - { - if ($valueNode instanceof StringValueNode) { - return Json::decodeIfJson($valueNode->value); - } - - if ($valueNode instanceof NullValueNode) { - return null; - } - - // This message will be lost by the wrapping exception, but it feels good to provide one. - throw new GqlException("Variants must be either an array, string or null."); - } -} From 994e29658d806d1ad42f4ca594d9ccafe4e2e964 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 25 Mar 2026 09:22:08 +0000 Subject: [PATCH 10/32] Update @since tags for new GraphQL product support to 7.1.0 --- src/elements/Product.php | 3 ++- src/gql/arguments/elements/Product.php | 2 +- src/gql/interfaces/elements/Product.php | 2 +- src/gql/queries/Product.php | 2 +- src/gql/resolvers/elements/Product.php | 2 +- src/gql/types/JsonType.php | 2 +- src/gql/types/elements/Product.php | 2 +- src/gql/types/generators/ProductType.php | 2 +- 8 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/elements/Product.php b/src/elements/Product.php index f0ec34a..3430945 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -195,7 +195,7 @@ public function init(): void /** * @inheritdoc - * @since 3.0 + * @since 7.1.0 */ public static function gqlScopesByContext(mixed $context): array { @@ -205,6 +205,7 @@ public static function gqlScopesByContext(mixed $context): array /** * @return string + * @since 7.1.0 */ public function getGqlTypeName(): string { diff --git a/src/gql/arguments/elements/Product.php b/src/gql/arguments/elements/Product.php index c5a7bf6..89e0125 100644 --- a/src/gql/arguments/elements/Product.php +++ b/src/gql/arguments/elements/Product.php @@ -18,7 +18,7 @@ * Class Product * * @author Pixel & Tonic, Inc. - * @since 6.1.0 + * @since 7.1.0 */ class Product extends ElementArguments { diff --git a/src/gql/interfaces/elements/Product.php b/src/gql/interfaces/elements/Product.php index 1fd14fb..a265905 100644 --- a/src/gql/interfaces/elements/Product.php +++ b/src/gql/interfaces/elements/Product.php @@ -21,7 +21,7 @@ * Class Product * * @author Pixel & Tonic, Inc. - * @since 6.1.0 + * @since 7.1.0 */ class Product extends Element { diff --git a/src/gql/queries/Product.php b/src/gql/queries/Product.php index f7e0411..5c1029a 100644 --- a/src/gql/queries/Product.php +++ b/src/gql/queries/Product.php @@ -18,7 +18,7 @@ * Class Product * * @author Pixel & Tonic, Inc. - * @since 6.1.0 + * @since 7.1.0 */ class Product extends Query { diff --git a/src/gql/resolvers/elements/Product.php b/src/gql/resolvers/elements/Product.php index 29a5fd2..6b62bde 100644 --- a/src/gql/resolvers/elements/Product.php +++ b/src/gql/resolvers/elements/Product.php @@ -16,7 +16,7 @@ * Class Product * * @author Pixel & Tonic, Inc. - * @since 6.1.0 + * @since 7.1.0 */ class Product extends ElementResolver { diff --git a/src/gql/types/JsonType.php b/src/gql/types/JsonType.php index 2720e51..9e5335c 100644 --- a/src/gql/types/JsonType.php +++ b/src/gql/types/JsonType.php @@ -21,7 +21,7 @@ * Class JsonType * * @author Pixel & Tonic, Inc. - * @since 6.1.0 + * @since 7.1.0 */ class JsonType extends ScalarType implements SingularTypeInterface { diff --git a/src/gql/types/elements/Product.php b/src/gql/types/elements/Product.php index 02db353..c3bafd5 100644 --- a/src/gql/types/elements/Product.php +++ b/src/gql/types/elements/Product.php @@ -15,7 +15,7 @@ * Class Product * * @author Pixel & Tonic, Inc. - * @since 6.1.0 + * @since 7.1.0 */ class Product extends ElementType { diff --git a/src/gql/types/generators/ProductType.php b/src/gql/types/generators/ProductType.php index eefd8e3..7aa3446 100644 --- a/src/gql/types/generators/ProductType.php +++ b/src/gql/types/generators/ProductType.php @@ -20,7 +20,7 @@ * Class ProductType * * @author Pixel & Tonic, Inc. - * @since 6.1.0 + * @since 7.1.0 */ class ProductType implements GeneratorInterface { From 6c9249e5c4ae1e9b8e8c8fe89925f25600119b81 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 25 Mar 2026 12:08:48 +0000 Subject: [PATCH 11/32] tidy --- src/Plugin.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Plugin.php b/src/Plugin.php index 2317e67..f41460e 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -263,7 +263,7 @@ private function _registerElementTypes(): void /** * Register the Gql interfaces - * @since 6.1.0 + * @since 7.1.0 */ private function _registerGqlInterfaces(): void { @@ -274,7 +274,7 @@ private function _registerGqlInterfaces(): void /** * Register the Gql queries - * @since 6.1.0 + * @since 7.1.0 */ private function _registerGqlQueries(): void { @@ -288,7 +288,7 @@ private function _registerGqlQueries(): void /** * Register the Gql permissions - * @since 6.1.0 + * @since 7.1.0 */ private function _registerGqlComponents(): void { From 2c685a8b8c71a1260fa756f19d20d913fb2f5418 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 25 Mar 2026 12:28:56 +0000 Subject: [PATCH 12/32] No longer applicable --- src/gql/arguments/elements/Product.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/gql/arguments/elements/Product.php b/src/gql/arguments/elements/Product.php index 89e0125..21bfde8 100644 --- a/src/gql/arguments/elements/Product.php +++ b/src/gql/arguments/elements/Product.php @@ -63,11 +63,6 @@ public static function getArguments(): array 'type' => Type::listOf(QueryArgument::getType()), 'description' => 'Narrows the query results based on the vendor on the product.', ], - 'publishedOnCurrentPublication' => [ - 'name' => 'publishedOnCurrentPublication', - 'type' => Type::boolean(), - 'description' => 'Narrows the query results based on the published on current publication on the product.', - ], ]); } From d77b20b1ba059e86ecbb16477ed57e21337ce08b Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 25 Mar 2026 12:36:36 +0000 Subject: [PATCH 13/32] no longer applicable --- src/gql/interfaces/elements/Product.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/gql/interfaces/elements/Product.php b/src/gql/interfaces/elements/Product.php index a265905..addc82e 100644 --- a/src/gql/interfaces/elements/Product.php +++ b/src/gql/interfaces/elements/Product.php @@ -100,11 +100,6 @@ public static function getFieldDefinitions(): array 'type' => Type::string(), 'description' => 'The product’s type in Shopify.', ], - 'publishedOnCurrentPublication' => [ - 'name' => 'publishedOnCurrentPublication', - 'type' => Type::boolean(), - 'description' => 'If the product is published on the current publication in Shopify.', - ], 'shopifyId' => [ 'name' => 'shopifyId', 'type' => Type::string(), From 8f9216a4178fc5209b9eb81b768b935f19b2b644 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Mon, 30 Mar 2026 16:57:41 +0100 Subject: [PATCH 14/32] Shopify Products GQL querying --- src/gql/arguments/elements/Product.php | 8 +- src/gql/interfaces/elements/Product.php | 30 ++- src/gql/types/Image.php | 113 +++++++++ src/gql/types/JsonType.php | 72 ------ src/gql/types/Metafield.php | 59 +++++ src/gql/types/Option.php | 85 +++++++ src/gql/types/Variant.php | 306 ++++++++++++++++++++++++ src/gql/types/elements/Product.php | 4 + src/services/Products.php | 2 +- 9 files changed, 595 insertions(+), 84 deletions(-) create mode 100644 src/gql/types/Image.php delete mode 100644 src/gql/types/JsonType.php create mode 100644 src/gql/types/Metafield.php create mode 100644 src/gql/types/Option.php create mode 100644 src/gql/types/Variant.php diff --git a/src/gql/arguments/elements/Product.php b/src/gql/arguments/elements/Product.php index 21bfde8..4b497ee 100644 --- a/src/gql/arguments/elements/Product.php +++ b/src/gql/arguments/elements/Product.php @@ -11,7 +11,6 @@ use craft\gql\base\ElementArguments; use craft\gql\types\QueryArgument; use craft\shopify\elements\Product as ProductElement; -use craft\shopify\Plugin; use GraphQL\Type\Definition\Type; /** @@ -63,6 +62,11 @@ public static function getArguments(): array 'type' => Type::listOf(QueryArgument::getType()), 'description' => 'Narrows the query results based on the vendor on the product.', ], + 'templateSuffix' => [ + 'name' => 'templateSuffix', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the “template suffix” selected in Shopify.', + ], ]); } @@ -72,7 +76,7 @@ public static function getArguments(): array public static function getContentArguments(): array { $productFieldsArguments = Craft::$app->getGql()->getContentArguments([ - Plugin::getInstance()->getSettings()->getProductFieldLayout(), + new ProductElement(), ], ProductElement::class); return array_merge(parent::getContentArguments(), $productFieldsArguments); diff --git a/src/gql/interfaces/elements/Product.php b/src/gql/interfaces/elements/Product.php index addc82e..e1017e5 100644 --- a/src/gql/interfaces/elements/Product.php +++ b/src/gql/interfaces/elements/Product.php @@ -13,7 +13,10 @@ use craft\gql\types\DateTime; use craft\shopify\elements\Product as ProductElement; use craft\shopify\gql\types\generators\ProductType; -use craft\shopify\gql\types\JsonType; +use craft\shopify\gql\types\Image; +use craft\shopify\gql\types\Metafield; +use craft\shopify\gql\types\Option; +use craft\shopify\gql\types\Variant; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\Type; @@ -120,30 +123,39 @@ public static function getFieldDefinitions(): array 'type' => Type::string(), 'description' => 'The product’s vendor in Shopify.', ], + 'templateSuffix' => [ + 'name' => 'templateSuffix', + 'type' => Type::string(), + 'description' => '', + ], + 'totalInventory' => [ + 'name' => 'totalInventory', + 'type' => Type::int(), + 'description' => 'The total inventory in Shopify.', + ], 'images' => [ 'name' => 'images', - 'type' => JsonType::getType(), + 'type' => Type::listOf(Image::getType()), 'description' => 'The product’s images in Shopify.', ], 'tags' => [ 'name' => 'tags', - 'type' => JsonType::getType(), + 'type' => Type::listOf(Type::string()), 'description' => 'The product’s tags in Shopify.', ], 'metafields' => [ 'name' => 'metafields', - 'type' => JsonType::getType(), + 'type' => Type::listOf(Metafield::getType()), 'description' => 'The product’s metafields in Shopify.', ], 'variants' => [ 'name' => 'variants', - 'type' => JsonType::getType(), + 'type' => Type::listOf(Variant::getType()), 'description' => 'The product’s variants in Shopify.', ], - 'data' => [ - 'name' => 'data', - 'type' => JsonType::getType(), - 'description' => 'The product’s synced data from Shopify.', + 'options' => [ + 'name' => 'options', + 'type' => Type::listOf(Option::getType()), ], ]), self::getName()); } diff --git a/src/gql/types/Image.php b/src/gql/types/Image.php new file mode 100644 index 0000000..6b1db58 --- /dev/null +++ b/src/gql/types/Image.php @@ -0,0 +1,113 @@ + + * @since 7.1.0 + */ +class Image extends ObjectType +{ + /** + * @return string + */ + public static function getName(): string + { + return 'ShopifyImage'; + } + + public static function getType(): Type + { + if ($type = GqlEntityRegistry::getEntity(self::getName())) { + return $type; + } + + return GqlEntityRegistry::createEntity(self::getName(), new self([ + 'name' => static::getName(), + 'fields' => self::class . '::getFieldDefinitions', + 'description' => '', + ])); + } + + public static function getFieldDefinitions(): array + { + return Craft::$app->getGql()->prepareFieldDefinitions([ + 'id' => [ + 'name' => 'id', + 'type' => Type::string(), + 'description' => 'Shopify GID of the image.', + ], + 'alt' => [ + 'name' => 'alt', + 'type' => Type::string(), + 'description' => 'Alt text of the image.', + ], + 'mediaContentType' => [ + 'name' => 'mediaContentType', + 'type' => Type::string(), + 'description' => 'Media content type of the image.', + ], + 'createdAt' => [ + 'name' => 'createdAt', + 'type' => Type::string(), + 'description' => 'Created date of the image.', + ], + 'updatedAt' => [ + 'name' => 'updatedAt', + 'type' => Type::string(), + 'description' => 'Updated date of the image.', + ], + 'image' => [ + 'name' => 'image', + 'type' => GqlEntityRegistry::getOrCreate('ShopifyImage::image', fn() => new \GraphQL\Type\Definition\ObjectType([ + 'name' => 'ShopifyImageImage', + 'fields' => [ + 'url' => [ + 'name' => 'url', + 'type' => Type::string(), + 'description' => 'URL of the image.', + ], + 'altText' => [ + 'name' => 'altText', + 'type' => Type::string(), + 'description' => 'Alt text of the image.', + ], + 'height' => [ + 'name' => 'height', + 'type' => Type::int(), + 'description' => 'Height of the image.', + ], + 'width' => [ + 'name' => 'width', + 'type' => Type::int(), + 'description' => 'Width of the image.', + ], + ], + ])), + ], + ], self::getName()); + } + + protected function resolve(mixed $source, array $arguments, mixed $context, ResolveInfo $resolveInfo): mixed + { + $fieldName = $resolveInfo->fieldName; + + return match ($fieldName) { + 'url' => $source['image']['url'], + default => parent::resolve($source, $arguments, $context, $resolveInfo), + }; + } +} diff --git a/src/gql/types/JsonType.php b/src/gql/types/JsonType.php deleted file mode 100644 index 9e5335c..0000000 --- a/src/gql/types/JsonType.php +++ /dev/null @@ -1,72 +0,0 @@ - - * @since 7.1.0 - */ -class JsonType extends ScalarType implements SingularTypeInterface -{ - /** - * @return string - */ - public static function getName(): string - { - return 'ShopifyJson'; - } - - public static function getType(): Type - { - return GqlEntityRegistry::getOrCreate(static::getName(), fn() => new self()); - } - - public function serialize($value) - { - if (empty($value)) { - return null; - } - - return $value; - } - - public function parseValue($value) - { - if (!is_string($value) && !is_array($value) && !is_null($value)) { - throw new GqlException('Data must be either a string, array, or null.'); - } - - return $value; - } - - public function parseLiteral(Node $valueNode, ?array $variables = null) - { - if ($valueNode instanceof StringValueNode) { - return Json::decodeIfJson($valueNode->value); - } - - if ($valueNode instanceof NullValueNode) { - return null; - } - - // This message will be lost by the wrapping exception, but it feels good to provide one. - throw new GqlException("Data must be either an array, string or null."); - } -} diff --git a/src/gql/types/Metafield.php b/src/gql/types/Metafield.php new file mode 100644 index 0000000..141b153 --- /dev/null +++ b/src/gql/types/Metafield.php @@ -0,0 +1,59 @@ + + * @since 7.1.0 + */ +class Metafield extends ObjectType +{ + /** + * @return string + */ + public static function getName(): string + { + return 'ShopifyMetafield'; + } + + public static function getType(): Type + { + if ($type = GqlEntityRegistry::getEntity(self::getName())) { + return $type; + } + + return GqlEntityRegistry::createEntity(self::getName(), new self([ + 'name' => static::getName(), + 'fields' => self::class . '::getFieldDefinitions', + 'description' => '', + ])); + } + + public static function getFieldDefinitions(): array + { + return Craft::$app->getGql()->prepareFieldDefinitions([ + 'key' => [ + 'name' => 'key', + 'type' => Type::string(), + 'description' => 'The key of the metafield.', + ], + 'value' => [ + 'name' => 'value', + 'type' => Type::string(), + 'description' => 'The value of the metafield.', + ], + ], self::getName()); + } +} diff --git a/src/gql/types/Option.php b/src/gql/types/Option.php new file mode 100644 index 0000000..d1231de --- /dev/null +++ b/src/gql/types/Option.php @@ -0,0 +1,85 @@ + + * @since 7.1.0 + */ +class Option extends ObjectType +{ + /** + * @return string + */ + public static function getName(): string + { + return 'ShopifyOption'; + } + + public static function getType(): Type + { + if ($type = GqlEntityRegistry::getEntity(self::getName())) { + return $type; + } + + return GqlEntityRegistry::createEntity(self::getName(), new self([ + 'name' => static::getName(), + 'fields' => self::class . '::getFieldDefinitions', + 'description' => '', + ])); + } + + public static function getFieldDefinitions(): array + { + return Craft::$app->getGql()->prepareFieldDefinitions([ + 'id' => [ + 'name' => 'id', + 'type' => Type::string(), + ], + 'name' => [ + 'name' => 'name', + 'type' => Type::string(), + ], + 'position' => [ + 'name' => 'position', + 'type' => Type::int(), + ], + 'values' => [ + 'name' => 'values', + 'type' => Type::listOf(Type::string()), + ], + 'optionValues' => [ + 'name' => 'optionValues', + 'type' => Type::listOf(GqlEntityRegistry::getOrCreate('ShopifyOptionOptionValue', fn() => new \GraphQL\Type\Definition\ObjectType([ + 'name' => 'ShopifyOptionOptionValue', + 'fields' => [ + 'id' => [ + 'name' => 'id', + 'type' => Type::string(), + ], + 'name' => [ + 'name' => 'name', + 'type' => Type::string(), + ], + 'hasVariants' => [ + 'name' => 'hasVariants', + 'type' => Type::boolean(), + ], + ], + ]))), + ], + ], self::getName()); + } +} diff --git a/src/gql/types/Variant.php b/src/gql/types/Variant.php new file mode 100644 index 0000000..402a6df --- /dev/null +++ b/src/gql/types/Variant.php @@ -0,0 +1,306 @@ + + * @since 7.1.0 + */ +class Variant extends ObjectType +{ + /** + * @return string + */ + public static function getName(): string + { + return 'ShopifyVariant'; + } + + public static function getType(): Type + { + if ($type = GqlEntityRegistry::getEntity(self::getName())) { + return $type; + } + + return GqlEntityRegistry::createEntity(self::getName(), new self([ + 'name' => static::getName(), + 'fields' => self::class . '::getFieldDefinitions', + 'description' => '', + ])); + } + + public static function getFieldDefinitions(): array + { + return Craft::$app->getGql()->prepareFieldDefinitions(array_merge(self::_contextualPricingCountriesFields(), [ + 'id' => [ + 'name' => 'id', + 'type' => Type::string(), + 'description' => 'Shopify GID of the variant.', + ], + 'title' => [ + 'name' => 'title', + 'type' => Type::string(), + 'description' => 'Title of the variant.', + ], + 'barcode' => [ + 'name' => 'barcode', + 'type' => Type::string(), + 'description' => 'Barcode of the variant.', + ], + 'compareAtPrice' => [ + 'name' => 'compareAtPrice', + 'type' => Type::string(), + 'description' => 'Compare at price of the variant.', + ], + 'createdAt' => [ + 'name' => 'createdAt', + 'type' => Type::string(), + 'description' => 'Created at of the variant.', + ], + 'displayName' => [ + 'name' => 'displayName', + 'type' => Type::string(), + 'description' => 'Display name of the variant.', + ], + 'price' => [ + 'name' => 'price', + 'type' => Type::string(), + 'description' => 'Price of the variant.', + ], + 'sku' => [ + 'name' => 'sku', + 'type' => Type::string(), + 'description' => 'Sku of the variant.', + ], + 'taxable' => [ + 'name' => 'taxable', + 'type' => Type::boolean(), + 'description' => 'Taxable price of the variant.', + ], + 'updatedAt' => [ + 'name' => 'updatedAt', + 'type' => Type::string(), + 'description' => 'Updated at of the variant.', + ], + 'position' => [ + 'name' => 'position', + 'type' => Type::int(), + 'description' => 'Position of the variant.', + ], + 'inventoryPolicy' => [ + 'name' => 'inventoryPolicy', + 'type' => Type::string(), + 'description' => 'Inventory policy of the variant.', + ], + 'inventoryQuantity' => [ + 'name' => 'inventoryQuantity', + 'type' => Type::int(), + 'description' => 'Inventory quantity of the variant.', + ], + 'metafields' => [ + 'name' => 'metafields', + 'type' => Type::listOf(Metafield::getType()), + 'description' => 'Metafields of the variant.', + ], + 'selectedOptions' => [ + 'name' => 'selectedOptions', + 'type' => Type::listOf(GqlEntityRegistry::getOrCreate('ShopifyVariantSelectionOption', fn() => new \GraphQL\Type\Definition\ObjectType([ + 'name' => 'ShopifyVariantSelectionOption', + 'fields' => [ + 'name' => [ + 'name' => 'name', + 'type' => Type::string(), + ], + 'value' => [ + 'name' => 'value', + 'type' => Type::string(), + ], + ], + ]))), + ], + 'product' => [ + 'name' => 'product', + 'type' => GqlEntityRegistry::getOrCreate('ShopifyVariantProduct', fn() => new \GraphQL\Type\Definition\ObjectType([ + 'name' => 'ShopifyVariantProduct', + 'fields' => [ + 'id' => [ + 'name' => 'id', + 'type' => Type::string(), + ], + ], + ])), + ], + 'image' => [ + 'name' => 'image', + 'type' => GqlEntityRegistry::getOrCreate('ShopifyVariantImage', fn() => new \GraphQL\Type\Definition\ObjectType([ + 'name' => 'ShopifyVariantImage', + 'fields' => [ + 'altText' => [ + 'name' => 'altText', + 'type' => Type::string(), + ], + 'height' => [ + 'name' => 'height', + 'type' => Type::int(), + ], + 'id' => [ + 'name' => 'id', + 'type' => Type::string(), + ], + 'url' => [ + 'name' => 'url', + 'type' => Type::string(), + ], + 'width' => [ + 'name' => 'width', + 'type' => Type::int(), + ], + 'originalSrc' => [ + 'name' => 'originalSrc', + 'type' => Type::string(), + ], + 'src' => [ + 'name' => 'src', + 'type' => Type::string(), + ], + 'transformedSrc' => [ + 'name' => 'transformedSrc', + 'type' => Type::string(), + ], + ], + ])), + ], + 'inventoryItem' => [ + 'name' => 'inventoryItem', + 'type' => GqlEntityRegistry::getOrCreate('ShopifyVariantInventoryItem', fn() => new \GraphQL\Type\Definition\ObjectType([ + 'name' => 'ShopifyVariantInventoryItem', + 'fields' => [ + 'id' => [ + 'name' => 'id', + 'type' => Type::string(), + ], + 'countryCodeOfOrigin' => [ + 'name' => 'countryCodeOfOrigin', + 'type' => Type::string(), + ], + 'createdAt' => [ + 'name' => 'createdAt', + 'type' => Type::string(), + ], + 'updatedAt' => [ + 'name' => 'updatedAt', + 'type' => Type::string(), + ], + 'sku' => [ + 'name' => 'sku', + 'type' => Type::string(), + ], + 'tracked' => [ + 'name' => 'tracked', + 'type' => Type::boolean(), + ], + 'unitCost' => [ + 'name' => 'unitCost', + 'type' => GqlEntityRegistry::getOrCreate('ShopifyVariantInventoryItemUnitCost', fn() => new \GraphQL\Type\Definition\ObjectType([ + 'name' => 'ShopifyVariantInventoryItemUnitCost', + 'fields' => [ + 'amount' => [ + 'name' => 'amount', + 'type' => Type::string(), + ], + 'currencyCode' => [ + 'name' => 'currencyCode', + 'type' => Type::string(), + ], + ], + ])), + ], + ], + ])), + ], + ]), self::getName()); + } + + private static function _contextualPricingCountriesFields(): array + { + $contextualPricingCountries = Plugin::getInstance()->getSettings()->getContextualPricingCountries(); + if (empty($contextualPricingCountries)) { + return []; + } + + $contextualPricing = []; + + $contextualPricingCountries = explode(',', $contextualPricingCountries); + foreach ($contextualPricingCountries as $country) { + // Keys cannot contain whitespace: + $key = trim($country); + + // Empty key, or not the right length? + if (!$key || strlen($key) !== 2) { + continue; + } + + $name = 'ShopifyVariantContextualPrice' . ucfirst(strtolower($key)); + + $contextualPricing[strtolower($key) . 'ContextualPricing'] = [ + 'name' => strtolower($key) . 'ContextualPricing', + 'type' => GqlEntityRegistry::getOrCreate($name, fn() => new \GraphQL\Type\Definition\ObjectType([ + 'name' => $name, + 'fields' => [ + 'price' => [ + 'name' => 'price', + 'type' => GqlEntityRegistry::getOrCreate($name . 'Price', fn() => new \GraphQL\Type\Definition\ObjectType([ + 'name' => $name . 'Price', + 'fields' => [ + 'amount' => [ + 'name' => 'amount', + 'type' => Type::string(), + 'description' => '', + ], + 'currencyCode' => [ + 'name' => 'currencyCode', + 'type' => Type::string(), + 'description' => '', + ], + ], + ])), + ], + 'compareAtPrice' => [ + 'name' => 'compareAtPrice', + 'type' => GqlEntityRegistry::getOrCreate($name . 'CompareAtPrice', fn() => new \GraphQL\Type\Definition\ObjectType([ + 'name' => $name . 'CompareAtPrice', + 'fields' => [ + 'amount' => [ + 'name' => 'amount', + 'type' => Type::string(), + 'description' => '', + ], + 'currencyCode' => [ + 'name' => 'currencyCode', + 'type' => Type::string(), + 'description' => '', + ], + ], + ])), + ], + ], + ])), + ]; + } + + return $contextualPricing; + } +} diff --git a/src/gql/types/elements/Product.php b/src/gql/types/elements/Product.php index c3bafd5..b72ebc8 100644 --- a/src/gql/types/elements/Product.php +++ b/src/gql/types/elements/Product.php @@ -37,6 +37,10 @@ protected function resolve(mixed $source, array $arguments, mixed $context, Reso return match ($fieldName) { // @TODO remove this when the conflict in (https://github.com/craftcms/cms/blob/7b889521442ef68be39edf52b55f4747da722e94/src/gql/ElementQueryConditionBuilder.php#L290-L321) is resolved 'variants' => $source->getVariants(), + // Remap metafields to be an array of key/value pairs instead of an associative array + 'metafields' => collect($source->getMetafields()) + ->map(fn($value, $key) => ['key' => $key, 'value' => $value]) + ->all(), default => parent::resolve($source, $arguments, $context, $resolveInfo), }; } diff --git a/src/services/Products.php b/src/services/Products.php index 05e54fd..f1de4dd 100644 --- a/src/services/Products.php +++ b/src/services/Products.php @@ -320,7 +320,7 @@ public function eagerLoadVariantsForProducts(array $products): array }); } - $product->setMetafields($variants); + $product->setVariants($variants); } return $return; From e171e715050f02989d1cdc9f3ff5a1fc50843a07 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 31 Mar 2026 09:21:35 +0100 Subject: [PATCH 15/32] Add returning of product element data (and querying) from product custom fields --- CHANGELOG.md | 8 +++++++- src/fields/Products.php | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0169913..63f8b18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,13 @@ ## WIP - 7.1.0 -- TBC +- Added `craft\shopify\gql\arguments\elements\Product`. +- Added `craft\shopify\gql\interfaces\elements\Product`. +- Added `craft\shopify\gql\types\elements\Product`. +- Added `craft\shopify\gql\types\Image`. +- Added `craft\shopify\gql\types\Metafield`. +- Added `craft\shopify\gql\types\Option`. +- Added `craft\shopify\gql\types\Variant`. ## 7.0.2 - 2026-03-24 diff --git a/src/fields/Products.php b/src/fields/Products.php index 8409bd6..99fd353 100755 --- a/src/fields/Products.php +++ b/src/fields/Products.php @@ -9,7 +9,12 @@ use Craft; use craft\fields\BaseRelationField; +use craft\helpers\Gql; use craft\shopify\elements\Product; +use craft\shopify\gql\arguments\elements\Product as ProductArguments; +use craft\shopify\gql\interfaces\elements\Product as ProductInterface; +use craft\shopify\gql\resolvers\elements\Product as ProductResolver; +use GraphQL\Type\Definition\Type; /** * Class Shopify Product Field @@ -44,4 +49,19 @@ public static function elementType(): string { return Product::class; } + + /** + * @inheritdoc + * @since 7.1.0 + */ + public function getContentGqlType(): Type|array + { + return [ + 'name' => $this->handle, + 'type' => Type::listOf(ProductInterface::getType()), + 'args' => ProductArguments::getArguments(), + 'resolve' => ProductResolver::class . '::resolve', + 'complexity' => Gql::eagerLoadComplexity(), + ]; + } } From cf32ad6ebdc92be282573780c960e016fcd711b7 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 31 Mar 2026 09:24:50 +0100 Subject: [PATCH 16/32] PHPstan fix --- src/gql/resolvers/elements/Product.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gql/resolvers/elements/Product.php b/src/gql/resolvers/elements/Product.php index 6b62bde..20274ca 100644 --- a/src/gql/resolvers/elements/Product.php +++ b/src/gql/resolvers/elements/Product.php @@ -10,6 +10,7 @@ use craft\elements\db\ElementQuery; use craft\gql\base\ElementResolver; use craft\helpers\Gql as GqlHelper; +use craft\shopify\elements\db\ProductQuery; use craft\shopify\elements\Product as ProductElement; /** @@ -56,6 +57,7 @@ public static function prepareQuery(mixed $source, array $arguments, $fieldName return []; } + /** @var ProductQuery $query */ $query->withAll(); return $query; From f2db62a44690f856df4a0b85d60e6d5a9237ce2a Mon Sep 17 00:00:00 2001 From: August Miller Date: Tue, 31 Mar 2026 10:51:31 -0700 Subject: [PATCH 17/32] Fix `totalInventory` access This is not a native Product attribute, so it requires special handling --- src/gql/types/elements/Product.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/gql/types/elements/Product.php b/src/gql/types/elements/Product.php index b72ebc8..f11c193 100644 --- a/src/gql/types/elements/Product.php +++ b/src/gql/types/elements/Product.php @@ -41,6 +41,7 @@ protected function resolve(mixed $source, array $arguments, mixed $context, Reso 'metafields' => collect($source->getMetafields()) ->map(fn($value, $key) => ['key' => $key, 'value' => $value]) ->all(), + 'totalInventory' => $source->data['totalInventory'], default => parent::resolve($source, $arguments, $context, $resolveInfo), }; } From b6a4980c2b656e4189243ca53e4550364acdfe58 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 1 Apr 2026 13:26:54 +0100 Subject: [PATCH 18/32] Add `totalInventory` to product element and gql queries --- CHANGELOG.md | 4 ++ src/Plugin.php | 2 +- src/elements/Product.php | 6 +++ src/elements/db/ProductQuery.php | 22 ++++++++++ src/gql/arguments/elements/Product.php | 5 +++ src/migrations/Install.php | 10 +++++ ...05_add_totalInventory_generated_column.php | 41 +++++++++++++++++++ 7 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/migrations/m260401_070605_add_totalInventory_generated_column.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f8b18..fdc2db1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## WIP - 7.1.0 +- Added the `totalInventory` product query param. +- Added `craft\shopify\elements\db\ProductQuery::$totalInventory`. +- Added `craft\shopify\elements\db\ProductQuery::totalInventory()`. +- Added `craft\shopify\elements\Product::$totalInventory`. - Added `craft\shopify\gql\arguments\elements\Product`. - Added `craft\shopify\gql\interfaces\elements\Product`. - Added `craft\shopify\gql\types\elements\Product`. diff --git a/src/Plugin.php b/src/Plugin.php index f41460e..a383013 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -79,7 +79,7 @@ class Plugin extends BasePlugin /** * @var string */ - public string $schemaVersion = '7.0.0.1'; + public string $schemaVersion = '7.1.0.0'; /** * @inheritdoc diff --git a/src/elements/Product.php b/src/elements/Product.php index 3430945..163e9b8 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -138,6 +138,12 @@ public function getDescriptionHtml(): ?string */ public ?string $templateSuffix = null; + /** + * @var int|null + * @since 7.1.0 + */ + public ?int $totalInventory = null; + /** * @var ?DateTime */ diff --git a/src/elements/db/ProductQuery.php b/src/elements/db/ProductQuery.php index abf7e06..66fc57b 100644 --- a/src/elements/db/ProductQuery.php +++ b/src/elements/db/ProductQuery.php @@ -62,6 +62,12 @@ class ProductQuery extends ElementQuery */ public mixed $templateSuffix = null; + /** + * @var mixed|null + * @since 7.1.0 + */ + public mixed $totalInventory = null; + /** * @var mixed|null */ @@ -257,6 +263,17 @@ public function templateSuffix(mixed $value): ProductQuery return $this; } + /** + * @param mixed $value + * @return ProductQuery + * @since 7.1.0 + */ + public function totalInventory(mixed $value): ProductQuery + { + $this->totalInventory = $value; + return $this; + } + /** * Narrows the query results based on the Shopify product ID */ @@ -395,6 +412,7 @@ protected function beforePrepare(): bool 'data.updatedAt', 'data.vendor', 'data.options', + 'data.totalInventory', 'data.data', ]); @@ -430,6 +448,10 @@ protected function beforePrepare(): bool $this->subQuery->andWhere(Db::parseParam('data.templateSuffix', $this->templateSuffix)); } + if (isset($this->totalInventory)) { + $this->subQuery->andWhere(Db::parseParam('data.totalInventory', $this->totalInventory)); + } + return parent::beforePrepare(); } } diff --git a/src/gql/arguments/elements/Product.php b/src/gql/arguments/elements/Product.php index 4b497ee..5f71cae 100644 --- a/src/gql/arguments/elements/Product.php +++ b/src/gql/arguments/elements/Product.php @@ -67,6 +67,11 @@ public static function getArguments(): array 'type' => Type::listOf(QueryArgument::getType()), 'description' => 'Narrows the query results based on the “template suffix” selected in Shopify.', ], + 'totalInventory' => [ + 'name' => 'totalInventory', + 'type' => Type::listOf(QueryArgument::getType()), + 'description' => 'Narrows the query results based on the total inventory on the product.', + ], ]); } diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 235bd65..6b429ac 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -121,6 +121,16 @@ public function createGeneratedColumns(): void $db->quoteColumnName($alias) . ' ' . $qb->getColumnType($this->text()) . " GENERATED ALWAYS AS (" . $qb->jsonExtract('data', [$col]) . ") STORED;"); } + + $intColumns = [ + 'totalInventory' => 'totalInventory', + ]; + + foreach ($intColumns as $col => $alias) { + $this->execute("ALTER TABLE " . Table::DATA . " ADD COLUMN " . + $db->quoteColumnName($alias) . ' ' . $qb->getColumnType($this->integer()) . " GENERATED ALWAYS AS (" . + $qb->jsonExtract('data', [$col]) . ") STORED;"); + } } /** diff --git a/src/migrations/m260401_070605_add_totalInventory_generated_column.php b/src/migrations/m260401_070605_add_totalInventory_generated_column.php new file mode 100644 index 0000000..9b9bb2e --- /dev/null +++ b/src/migrations/m260401_070605_add_totalInventory_generated_column.php @@ -0,0 +1,41 @@ +db->columnExists(Table::DATA, $col)) { + return true; + } + + $db = $this->getDb(); + $qb = $db->getQueryBuilder(); + + $this->execute("ALTER TABLE " . Table::DATA . " ADD COLUMN " . + $db->quoteColumnName($col) . ' ' . $qb->getColumnType($this->integer()) . " GENERATED ALWAYS AS (" . + $qb->jsonExtract('data', [$col]) . ") STORED;"); + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m260401_070605_add_totalInventory_generated_column cannot be reverted.\n"; + return false; + } +} From c63216effd39eb098b76c9e4185213700a372e65 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 1 Apr 2026 13:27:09 +0100 Subject: [PATCH 19/32] Revert "Fix `totalInventory` access" This reverts commit f2db62a44690f856df4a0b85d60e6d5a9237ce2a. --- src/gql/types/elements/Product.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/gql/types/elements/Product.php b/src/gql/types/elements/Product.php index f11c193..b72ebc8 100644 --- a/src/gql/types/elements/Product.php +++ b/src/gql/types/elements/Product.php @@ -41,7 +41,6 @@ protected function resolve(mixed $source, array $arguments, mixed $context, Reso 'metafields' => collect($source->getMetafields()) ->map(fn($value, $key) => ['key' => $key, 'value' => $value]) ->all(), - 'totalInventory' => $source->data['totalInventory'], default => parent::resolve($source, $arguments, $context, $resolveInfo), }; } From 06a75a55df65f4bccaff122522fd73985c8c024e Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 1 Apr 2026 13:45:29 +0100 Subject: [PATCH 20/32] Tidy product type GQL generation --- src/elements/Product.php | 5 +++ src/gql/types/generators/ProductType.php | 48 ++++++++++++------------ 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/elements/Product.php b/src/elements/Product.php index 163e9b8..1b7ad5f 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -62,6 +62,11 @@ class Product extends Element public const SHOPIFY_STATUS_DRAFT = 'draft'; public const SHOPIFY_STATUS_ARCHIVED = 'archived'; + /** + * @since 7.1.0 + */ + public const GQL_TYPE_NAME = 'ShopifyProduct'; + /** * @return string|null * @since 6.0.0 diff --git a/src/gql/types/generators/ProductType.php b/src/gql/types/generators/ProductType.php index 7aa3446..386bcdf 100644 --- a/src/gql/types/generators/ProductType.php +++ b/src/gql/types/generators/ProductType.php @@ -8,13 +8,14 @@ namespace craft\shopify\gql\types\generators; use Craft; -use craft\base\Field; +use craft\gql\base\Generator; use craft\gql\base\GeneratorInterface; +use craft\gql\base\ObjectType; +use craft\gql\base\SingleGeneratorInterface; use craft\gql\GqlEntityRegistry; use craft\shopify\elements\Product as ProductElement; use craft\shopify\gql\interfaces\elements\Product as ProductInterface; -use craft\shopify\gql\types\elements\Product as ProductTypeElement; -use craft\shopify\Plugin; +use craft\shopify\gql\types\elements\Product; /** * Class ProductType @@ -22,32 +23,33 @@ * @author Pixel & Tonic, Inc. * @since 7.1.0 */ -class ProductType implements GeneratorInterface +class ProductType extends Generator implements GeneratorInterface, SingleGeneratorInterface { /** * @inheritdoc */ public static function generateTypes(mixed $context = null): array { - $productFieldLayout = Plugin::getInstance()->getSettings()->getProductFieldLayout(); - - $typeName = (new ProductElement())->getGqlTypeName(); - $contentFields = $productFieldLayout->getCustomFields(); - $contentFieldGqlTypes = []; - - /** @var Field $contentField */ - foreach ($contentFields as $contentField) { - $contentFieldGqlTypes[$contentField->handle] = $contentField->getContentGqlType(); - } + $type = static::generateType($context); + return [$type->name => $type]; + } - $productFields = Craft::$app->getGql()->prepareFieldDefinitions(array_merge(ProductInterface::getFieldDefinitions(), $contentFieldGqlTypes), $typeName); - return [ - $typeName => GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new ProductTypeElement([ - 'name' => $typeName, - 'fields' => function() use ($productFields) { - return $productFields; - }, - ])), - ]; + /** + * @inheritdoc + */ + public static function generateType(mixed $context): ObjectType + { + return GqlEntityRegistry::getOrCreate(ProductElement::GQL_TYPE_NAME, fn() => new Product([ + 'name' => ProductElement::GQL_TYPE_NAME, + 'fields' => function() use ($context) { + $context ??= Craft::$app->getFields()->getLayoutByType(ProductElement::class); + $contentFieldGqlTypes = self::getContentFields($context); + $productFields = array_merge(ProductInterface::getFieldDefinitions(), $contentFieldGqlTypes); + return Craft::$app->getGql()->prepareFieldDefinitions( + $productFields, + ProductElement::GQL_TYPE_NAME + ); + }, + ])); } } From 9cd8c13b056b44f4f0a7c6bdcebd7556e75921a7 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 1 Apr 2026 19:12:32 -0700 Subject: [PATCH 21/32] Add general GraphQL note to changelog, separate into categories --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdc2db1..d44fae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## WIP - 7.1.0 +### Development + +- Product elements are now available via Craft’s own GraphQL API. ([#206](https://github.com/craftcms/shopify/pull/206)) + +### Extensibility + - Added the `totalInventory` product query param. - Added `craft\shopify\elements\db\ProductQuery::$totalInventory`. - Added `craft\shopify\elements\db\ProductQuery::totalInventory()`. From daea7ef7183df0a78cadb3178a0560d5f5c31d99 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 1 Apr 2026 19:15:27 -0700 Subject: [PATCH 22/32] Add GraphQL documentation --- README.md | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/README.md b/README.md index 93e52b3..c12b6a0 100644 --- a/README.md +++ b/README.md @@ -572,6 +572,108 @@ Filter by the vendor information from Shopify. You can still access `product.variants`, `product.images`, and `product.metafields` without eager-loading—but it may result in an additional query for each kind of content. Once you’ve retrieved variants, for example, they are memoized on the product element instance for the duration of the request. +### GraphQL + +Product elements are also exposed via [Craft’s GraphQL API](https://craftcms.com/docs/5.x/development/graphql.html). +You can fetch products (and any content added via custom fields) using the `shopifyProducts()` query: + +```graphql +query PowerTools { + shopifyProducts(productType: "powertools") { + # Native Product element properties: + id + title + shopifyStatus + images { + image { + url + altText + width + height + } + } + + # Custom fields, from the field layout in Craft: + ... on ShopifyProduct { + brandName + brandFamily + } + } +} +``` + +Products can be retrieved one at a time with the `shopifyProduct` (singular) query. +You might use this in a headless front-end to resolve a product by its slug, based on your routing: + +```graphql +query OneProductBySlug($slug: [String]) { + shopifyProduct(slug: $slug) { + id + title + # ... + } +} + +# Variables: +# { +# slug: Astro.params.slug +# } +``` + +It’s important to note that the GraphQL schema is _not_ the same as directly accessing the Shopify API, and that the built-in documentation only reflects what is accessible via Craft. +You’ll encounter many _familiar_ objects, but only a subset of the types and fields are available. +Only the data retrieved during synchronization (plus your custom fields and other native element properties) will be present in the API. + +Arguments are also radically different: Shopify’s filtering is primarily accomplished via the single, generic [`query`](https://shopify.dev/docs/api/admin-graphql/latest/queries/products#arguments-query) param. +Craft uses dedicated field names and types for argument inputs, so the above single- and multi-product queries accept any combination of criteria, including references to custom fields. + +> [!TIP] +> Use the [GraphiQL IDE](https://craftcms.com/docs/5.x/development/graphql.html#using-the-graphiql-ide) in Craft’s control panel to explore the self-documenting API! + +#### Extending + +GraphQL is inherently strictly “typed,” which means that the arbitrary shape of Products’ `data` attribute is not selectable or navigable. + +If you wish to expose [additional fields](#craftshopifyservicesapievent_define_product_gql_fields) you have synchronized from the API, they must be added as Craft builds the product element schema: + +```php +use craft\base\Event; +use craft\events\DefineGqlTypeFieldsEvent; +use craft\gql\TypeManager; +use craft\shopify\elements\Product; +use GraphQL\Type\Definition\Type; + +Event::on( + TypeManager::class, + TypeManager::EVENT_DEFINE_GQL_TYPE_FIELDS, + function(DefineGqlTypeFieldsEvent $event) { + // Exit early unless it’s the “type” definition we want to modify: + if ($event->typeName !== Product::GQL_TYPE_NAME) { + return; + } + + // Register a new field that can resolve the supplemental `data`: + $event->fields['shopifyCategory'] = [ + 'name' => 'shopifyCategory', + 'type' => Type::string(), + 'description' => 'The Shopify “Standard Product Taxonomy” name.', + 'resolve' => function(Product $source) { + return $source->getData()['category']['name'] ?? null; + }, + ]; + } +) +``` + +As long as you keep your “resolver” in sync with your additional selections, you should be able to pass through those values. +The same strategy can be used to decorate the built-in + +> [!NOTE] +> These values are not dynamically fetched from Shopify! +> This method just exposes additional fields that have already been synchronized from Shopify. + + + ## Templating ### Product Data From 5171248b33485b2dbae171b5a8ca335fb12d3dc3 Mon Sep 17 00:00:00 2001 From: August Miller Date: Wed, 1 Apr 2026 19:16:12 -0700 Subject: [PATCH 23/32] Mention changelog in readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c12b6a0..de35d23 100644 --- a/README.md +++ b/README.md @@ -208,12 +208,13 @@ Discover orphaned subscriptions using the [`webhookSubscriptions()`](https://sho ## Upgrading -This release (7.x) is primarily concerned with Shopify API compatability, but the [new authentication mechanism](#connect-to-shopify) means that you’ll need to re-establish the connection to Shopify using the authentication scheme [described above](#connect-to-shopify). +This version (7.x) is primarily concerned with Shopify API compatability, but the [new authentication mechanism](#connect-to-shopify) means that you’ll need to re-establish the connection to Shopify using the authentication scheme [described above](#connect-to-shopify). Due to significant shifts in Shopify’s developer ecosystem, many of the [front-end cart management](#front-end-sdks) techniques we have recommended (like the _JS Buy SDK_ and _Buy Button JS_) are no longer viable. > [!TIP] > We strongly recommend reviewing this same section on the [6.x](https://github.com/craftcms/shopify/blob/6.x/README.md#upgrading) branch, as there were a number of breaking changes and deprecations during the upgrade from 5.x. +> The [changelog](https://github.com/craftcms/shopify/blob/7.x/CHANGELOG.md) contains specific information about the classes and methods that have been added, removed, or deprecated. After the upgrade, you **must** [delete and re-create](#set-up-webhooks) webhooks for each environment. Webhooks are registered and delivered with a specific version, and a mismatch will result in errors. From 226d7bbc8d638ccb7af6dc6921814ff3018258d6 Mon Sep 17 00:00:00 2001 From: August Miller Date: Thu, 2 Apr 2026 11:01:03 -0700 Subject: [PATCH 24/32] Complete sentence in GQL docs --- README.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index de35d23..e8c6a16 100644 --- a/README.md +++ b/README.md @@ -667,10 +667,21 @@ Event::on( ``` As long as you keep your “resolver” in sync with your additional selections, you should be able to pass through those values. -The same strategy can be used to decorate the built-in +The same strategy can be used to decorate other [built-in types](https://github.com/craftcms/shopify/tree/7.x/src/gql/types/) for images, metafields, options, and variants. +You can alter multiple types in a single `EVENT_DEFINE_GQL_TYPE_FIELDS` handler: -> [!NOTE] -> These values are not dynamically fetched from Shopify! +```php +if ($event->typeName === \craft\shopify\elements\Product::GQL_TYPE_NAME) { + // Manipulate product element fields... +} + +if ($event->typeName === \craft\shopify\gql\types\Image::getName()) { + // Manipulate fields on "image" objects... +} +``` + +> [!WARNING] +> The fields we’ve added are not dynamically fetched from Shopify at runtime! > This method just exposes additional fields that have already been synchronized from Shopify. From 93395db5107f0d173603dfefd0a1ff8256626d1e Mon Sep 17 00:00:00 2001 From: August Miller Date: Thu, 2 Apr 2026 11:02:06 -0700 Subject: [PATCH 25/32] speling :) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e8c6a16..6a8456b 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ Discover orphaned subscriptions using the [`webhookSubscriptions()`](https://sho ## Upgrading -This version (7.x) is primarily concerned with Shopify API compatability, but the [new authentication mechanism](#connect-to-shopify) means that you’ll need to re-establish the connection to Shopify using the authentication scheme [described above](#connect-to-shopify). +This version (7.x) is primarily concerned with Shopify API compatibility, but the [new authentication mechanism](#connect-to-shopify) means that you’ll need to re-establish the connection to Shopify using the authentication scheme [described above](#connect-to-shopify). Due to significant shifts in Shopify’s developer ecosystem, many of the [front-end cart management](#front-end-sdks) techniques we have recommended (like the _JS Buy SDK_ and _Buy Button JS_) are no longer viable. From ee5b98b655de9e8e4ca4a4e33ac9b66ca78ef1cf Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 3 Apr 2026 05:50:21 +0100 Subject: [PATCH 26/32] Fix postgres generated column --- src/migrations/Install.php | 10 +++++++++- ...0401_070605_add_totalInventory_generated_column.php | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 6b429ac..fffe0c9 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -127,9 +127,17 @@ public function createGeneratedColumns(): void ]; foreach ($intColumns as $col => $alias) { + $expression = $qb->jsonExtract('data', [$col]); + + if ($db->getIsPgsql()) { + $expression = "($expression)::int"; + } else { + $expression = "CAST(JSON_UNQUOTE($expression) AS SIGNED)"; + } + $this->execute("ALTER TABLE " . Table::DATA . " ADD COLUMN " . $db->quoteColumnName($alias) . ' ' . $qb->getColumnType($this->integer()) . " GENERATED ALWAYS AS (" . - $qb->jsonExtract('data', [$col]) . ") STORED;"); + $expression . ") STORED;"); } } diff --git a/src/migrations/m260401_070605_add_totalInventory_generated_column.php b/src/migrations/m260401_070605_add_totalInventory_generated_column.php index 9b9bb2e..8d7a154 100644 --- a/src/migrations/m260401_070605_add_totalInventory_generated_column.php +++ b/src/migrations/m260401_070605_add_totalInventory_generated_column.php @@ -23,9 +23,17 @@ public function safeUp(): bool $db = $this->getDb(); $qb = $db->getQueryBuilder(); + $expression = $qb->jsonExtract('data', [$col]); + + if ($db->getIsPgsql()) { + $expression = "($expression)::int"; + } else { + $expression = "CAST(JSON_UNQUOTE($expression) AS SIGNED)"; + } + $this->execute("ALTER TABLE " . Table::DATA . " ADD COLUMN " . $db->quoteColumnName($col) . ' ' . $qb->getColumnType($this->integer()) . " GENERATED ALWAYS AS (" . - $qb->jsonExtract('data', [$col]) . ") STORED;"); + $expression . ") STORED;"); return true; } From 716d1e71ccc938a7c5103ee33cf2de76b68f0cf0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 04:55:09 +0000 Subject: [PATCH 27/32] Use GQL_TYPE_NAME constant in gqlScopesByContext() and getGqlTypeName() Agent-Logs-Url: https://github.com/craftcms/shopify/sessions/435f4462-160e-4fa6-bf94-06f123b0c9be Co-authored-by: nfourtythree <266453+nfourtythree@users.noreply.github.com> --- src/elements/Product.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/elements/Product.php b/src/elements/Product.php index 1b7ad5f..75975b1 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -211,7 +211,7 @@ public function init(): void public static function gqlScopesByContext(mixed $context): array { /** @var FieldLayout $context */ - return ['ShopifyProduct']; + return [self::GQL_TYPE_NAME]; } /** @@ -220,7 +220,7 @@ public static function gqlScopesByContext(mixed $context): array */ public function getGqlTypeName(): string { - return 'ShopifyProduct'; + return self::GQL_TYPE_NAME; } /** From 9c68c1222155cbfd67b2236bbef88ee9db601699 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 3 Apr 2026 06:09:10 +0100 Subject: [PATCH 28/32] Make sure metafields are re-mapped with key and value props in gql --- src/gql/interfaces/elements/Product.php | 6 ++++++ src/gql/types/Variant.php | 7 +++++++ src/gql/types/elements/Product.php | 4 ---- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/gql/interfaces/elements/Product.php b/src/gql/interfaces/elements/Product.php index e1017e5..56160ba 100644 --- a/src/gql/interfaces/elements/Product.php +++ b/src/gql/interfaces/elements/Product.php @@ -147,6 +147,12 @@ public static function getFieldDefinitions(): array 'name' => 'metafields', 'type' => Type::listOf(Metafield::getType()), 'description' => 'The product’s metafields in Shopify.', + 'resolve' => function(\craft\shopify\elements\Product $source) { + // Remap metafields to be an array of key/value pairs instead of an associative array + return collect($source->getMetafields()) + ->map(fn($value, $key) => ['key' => $key, 'value' => $value]) + ->all(); + }, ], 'variants' => [ 'name' => 'variants', diff --git a/src/gql/types/Variant.php b/src/gql/types/Variant.php index 402a6df..1beaadd 100644 --- a/src/gql/types/Variant.php +++ b/src/gql/types/Variant.php @@ -10,7 +10,9 @@ use Craft; use craft\gql\base\ObjectType; use craft\gql\GqlEntityRegistry; +use craft\shopify\models\Variant as VariantElement; use craft\shopify\Plugin; +use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; /** @@ -114,6 +116,11 @@ public static function getFieldDefinitions(): array 'name' => 'metafields', 'type' => Type::listOf(Metafield::getType()), 'description' => 'Metafields of the variant.', + 'resolve' => function (VariantElement $source) { + return collect($source->getMetafields()) + ->map(fn($value, $key) => ['key' => $key, 'value' => $value]) + ->all(); + } ], 'selectedOptions' => [ 'name' => 'selectedOptions', diff --git a/src/gql/types/elements/Product.php b/src/gql/types/elements/Product.php index b72ebc8..c3bafd5 100644 --- a/src/gql/types/elements/Product.php +++ b/src/gql/types/elements/Product.php @@ -37,10 +37,6 @@ protected function resolve(mixed $source, array $arguments, mixed $context, Reso return match ($fieldName) { // @TODO remove this when the conflict in (https://github.com/craftcms/cms/blob/7b889521442ef68be39edf52b55f4747da722e94/src/gql/ElementQueryConditionBuilder.php#L290-L321) is resolved 'variants' => $source->getVariants(), - // Remap metafields to be an array of key/value pairs instead of an associative array - 'metafields' => collect($source->getMetafields()) - ->map(fn($value, $key) => ['key' => $key, 'value' => $value]) - ->all(), default => parent::resolve($source, $arguments, $context, $resolveInfo), }; } From b903160b7f5603c8e9d13f72c31b3dc3f5f68eb6 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 3 Apr 2026 06:09:59 +0100 Subject: [PATCH 29/32] fix cs --- src/gql/types/Variant.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/gql/types/Variant.php b/src/gql/types/Variant.php index 1beaadd..2a47f66 100644 --- a/src/gql/types/Variant.php +++ b/src/gql/types/Variant.php @@ -12,7 +12,6 @@ use craft\gql\GqlEntityRegistry; use craft\shopify\models\Variant as VariantElement; use craft\shopify\Plugin; -use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; /** @@ -116,11 +115,11 @@ public static function getFieldDefinitions(): array 'name' => 'metafields', 'type' => Type::listOf(Metafield::getType()), 'description' => 'Metafields of the variant.', - 'resolve' => function (VariantElement $source) { + 'resolve' => function(VariantElement $source) { return collect($source->getMetafields()) ->map(fn($value, $key) => ['key' => $key, 'value' => $value]) ->all(); - } + }, ], 'selectedOptions' => [ 'name' => 'selectedOptions', From 33b1b457cdd2e9523f77591ec2d6dea272053bb6 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 3 Apr 2026 06:17:26 +0100 Subject: [PATCH 30/32] Make sure metafield `value` is always being returned as a string for the GQL schema --- src/gql/interfaces/elements/Product.php | 4 +++- src/gql/types/Variant.php | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/gql/interfaces/elements/Product.php b/src/gql/interfaces/elements/Product.php index 56160ba..61f15f4 100644 --- a/src/gql/interfaces/elements/Product.php +++ b/src/gql/interfaces/elements/Product.php @@ -11,6 +11,7 @@ use craft\gql\GqlEntityRegistry; use craft\gql\interfaces\Element; use craft\gql\types\DateTime; +use craft\helpers\Json; use craft\shopify\elements\Product as ProductElement; use craft\shopify\gql\types\generators\ProductType; use craft\shopify\gql\types\Image; @@ -150,7 +151,8 @@ public static function getFieldDefinitions(): array 'resolve' => function(\craft\shopify\elements\Product $source) { // Remap metafields to be an array of key/value pairs instead of an associative array return collect($source->getMetafields()) - ->map(fn($value, $key) => ['key' => $key, 'value' => $value]) + // Ensure value is encoded as we aren't sure of its type + ->map(fn($value, $key) => ['key' => $key, 'value' => !is_string($value) ? Json::encode($value) : $value]) ->all(); }, ], diff --git a/src/gql/types/Variant.php b/src/gql/types/Variant.php index 2a47f66..5e6b05e 100644 --- a/src/gql/types/Variant.php +++ b/src/gql/types/Variant.php @@ -10,6 +10,7 @@ use Craft; use craft\gql\base\ObjectType; use craft\gql\GqlEntityRegistry; +use craft\helpers\Json; use craft\shopify\models\Variant as VariantElement; use craft\shopify\Plugin; use GraphQL\Type\Definition\Type; @@ -117,7 +118,8 @@ public static function getFieldDefinitions(): array 'description' => 'Metafields of the variant.', 'resolve' => function(VariantElement $source) { return collect($source->getMetafields()) - ->map(fn($value, $key) => ['key' => $key, 'value' => $value]) + // Ensure value is encoded as we aren't sure of its type + ->map(fn($value, $key) => ['key' => $key, 'value' => !is_string($value) ? Json::encode($value) : $value]) ->all(); }, ], From a7cbd610a0cf0a5d4db0347b3834d33ea471c06c Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 3 Apr 2026 06:20:35 +0100 Subject: [PATCH 31/32] Remove no longer need `url` remapping in `Image` GQL type --- src/gql/types/Image.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/gql/types/Image.php b/src/gql/types/Image.php index 6b1db58..6a36121 100644 --- a/src/gql/types/Image.php +++ b/src/gql/types/Image.php @@ -100,14 +100,4 @@ public static function getFieldDefinitions(): array ], ], self::getName()); } - - protected function resolve(mixed $source, array $arguments, mixed $context, ResolveInfo $resolveInfo): mixed - { - $fieldName = $resolveInfo->fieldName; - - return match ($fieldName) { - 'url' => $source['image']['url'], - default => parent::resolve($source, $arguments, $context, $resolveInfo), - }; - } } From 35fabb8c125d7a44a90a06c12e8ae411bac1cada Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 3 Apr 2026 06:21:21 +0100 Subject: [PATCH 32/32] fix cs --- src/gql/types/Image.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/gql/types/Image.php b/src/gql/types/Image.php index 6a36121..18b5ca7 100644 --- a/src/gql/types/Image.php +++ b/src/gql/types/Image.php @@ -10,7 +10,6 @@ use Craft; use craft\gql\base\ObjectType; use craft\gql\GqlEntityRegistry; -use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; /**