diff --git a/CHANGELOG.md b/CHANGELOG.md index 0169913..d44fae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,23 @@ ## WIP - 7.1.0 -- TBC +### 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()`. +- 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`. +- 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/README.md b/README.md index 93e52b3..6a8456b 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 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. > [!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. @@ -572,6 +573,119 @@ 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 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: + +```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. + + + ## Templating ### Product Data diff --git a/src/Plugin.php b/src/Plugin.php index 7fbaa73..a383013 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -20,6 +20,9 @@ use craft\events\DefineConsoleActionsEvent; use craft\events\DefineFieldLayoutFieldsEvent; 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; @@ -30,6 +33,7 @@ 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; @@ -39,6 +43,8 @@ use craft\shopify\fieldlayoutelements\OptionsField; use craft\shopify\fieldlayoutelements\VariantsField; 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; @@ -73,7 +79,7 @@ class Plugin extends BasePlugin /** * @var string */ - public string $schemaVersion = '7.0.0.1'; + public string $schemaVersion = '7.1.0.0'; /** * @inheritdoc @@ -137,6 +143,9 @@ public function init() $this->_registerResaveCommands(); $this->_registerGarbageCollection(); $this->_registerFeedMeEvents(); + $this->_registerGqlInterfaces(); + $this->_registerGqlQueries(); + $this->_registerGqlComponents(); if (!$request->getIsConsoleRequest()) { if ($request->getIsCpRequest()) { @@ -251,6 +260,49 @@ private function _registerElementTypes(): void }); } + + /** + * Register the Gql interfaces + * @since 7.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 7.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 7.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 7b88608..75975b1 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 @@ -138,6 +143,12 @@ public function getDescriptionHtml(): ?string */ public ?string $templateSuffix = null; + /** + * @var int|null + * @since 7.1.0 + */ + public ?int $totalInventory = null; + /** * @var ?DateTime */ @@ -193,6 +204,25 @@ public function init(): void parent::init(); } + /** + * @inheritdoc + * @since 7.1.0 + */ + public static function gqlScopesByContext(mixed $context): array + { + /** @var FieldLayout $context */ + return [self::GQL_TYPE_NAME]; + } + + /** + * @return string + * @since 7.1.0 + */ + public function getGqlTypeName(): string + { + return self::GQL_TYPE_NAME; + } + /** * @inheritdoc */ diff --git a/src/elements/db/ProductQuery.php b/src/elements/db/ProductQuery.php index 5be84cd..66fc57b 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 */ @@ -63,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 */ @@ -258,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 */ @@ -396,6 +412,7 @@ protected function beforePrepare(): bool 'data.updatedAt', 'data.vendor', 'data.options', + 'data.totalInventory', 'data.data', ]); @@ -431,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/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(), + ]; + } } diff --git a/src/gql/arguments/elements/Product.php b/src/gql/arguments/elements/Product.php new file mode 100644 index 0000000..5f71cae --- /dev/null +++ b/src/gql/arguments/elements/Product.php @@ -0,0 +1,89 @@ + + * @since 7.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.', + ], + '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.', + ], + 'templateSuffix' => [ + 'name' => 'templateSuffix', + '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.', + ], + ]); + } + + /** + * @inheritdoc + */ + public static function getContentArguments(): array + { + $productFieldsArguments = Craft::$app->getGql()->getContentArguments([ + 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 new file mode 100644 index 0000000..61f15f4 --- /dev/null +++ b/src/gql/interfaces/elements/Product.php @@ -0,0 +1,170 @@ + + * @since 7.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(), [ + '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.', + ], + '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.', + ], + 'templateSuffix' => [ + 'name' => 'templateSuffix', + 'type' => Type::string(), + 'description' => '', + ], + 'totalInventory' => [ + 'name' => 'totalInventory', + 'type' => Type::int(), + 'description' => 'The total inventory in Shopify.', + ], + 'images' => [ + 'name' => 'images', + 'type' => Type::listOf(Image::getType()), + 'description' => 'The product’s images in Shopify.', + ], + 'tags' => [ + 'name' => 'tags', + 'type' => Type::listOf(Type::string()), + 'description' => 'The product’s tags in Shopify.', + ], + 'metafields' => [ + '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()) + // 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(); + }, + ], + 'variants' => [ + 'name' => 'variants', + 'type' => Type::listOf(Variant::getType()), + 'description' => 'The product’s variants in Shopify.', + ], + 'options' => [ + 'name' => 'options', + 'type' => Type::listOf(Option::getType()), + ], + ]), self::getName()); + } +} diff --git a/src/gql/queries/Product.php b/src/gql/queries/Product.php new file mode 100644 index 0000000..5c1029a --- /dev/null +++ b/src/gql/queries/Product.php @@ -0,0 +1,57 @@ + + * @since 7.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..20274ca --- /dev/null +++ b/src/gql/resolvers/elements/Product.php @@ -0,0 +1,65 @@ + + * @since 7.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 []; + } + + /** @var ProductQuery $query */ + $query->withAll(); + + return $query; + } +} diff --git a/src/gql/types/Image.php b/src/gql/types/Image.php new file mode 100644 index 0000000..18b5ca7 --- /dev/null +++ b/src/gql/types/Image.php @@ -0,0 +1,102 @@ + + * @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()); + } +} 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..5e6b05e --- /dev/null +++ b/src/gql/types/Variant.php @@ -0,0 +1,314 @@ + + * @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.', + 'resolve' => function(VariantElement $source) { + return collect($source->getMetafields()) + // 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(); + }, + ], + '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 new file mode 100644 index 0000000..c3bafd5 --- /dev/null +++ b/src/gql/types/elements/Product.php @@ -0,0 +1,43 @@ + + * @since 7.1.0 + */ +class Product extends ElementType +{ + /** + * @inheritdoc + */ + public function __construct(array $config) + { + $config['interfaces'] = [ + ProductInterface::getType(), + ]; + + 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), + }; + } +} diff --git a/src/gql/types/generators/ProductType.php b/src/gql/types/generators/ProductType.php new file mode 100644 index 0000000..386bcdf --- /dev/null +++ b/src/gql/types/generators/ProductType.php @@ -0,0 +1,55 @@ + + * @since 7.1.0 + */ +class ProductType extends Generator implements GeneratorInterface, SingleGeneratorInterface +{ + /** + * @inheritdoc + */ + public static function generateTypes(mixed $context = null): array + { + $type = static::generateType($context); + return [$type->name => $type]; + } + + /** + * @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 + ); + }, + ])); + } +} diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 235bd65..fffe0c9 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -121,6 +121,24 @@ 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) { + $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 (" . + $expression . ") 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..8d7a154 --- /dev/null +++ b/src/migrations/m260401_070605_add_totalInventory_generated_column.php @@ -0,0 +1,49 @@ +db->columnExists(Table::DATA, $col)) { + return true; + } + + $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 (" . + $expression . ") STORED;"); + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m260401_070605_add_totalInventory_generated_column cannot be reverted.\n"; + return false; + } +} 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; diff --git a/src/translations/en/shopify.php b/src/translations/en/shopify.php index dc92d6a..78fcda7 100644 --- a/src/translations/en/shopify.php +++ b/src/translations/en/shopify.php @@ -98,6 +98,7 @@ 'Variant' => 'Variant', '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.',