diff --git a/src/Plugin.php b/src/Plugin.php index 7fbaa73..f41460e 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; @@ -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..3430945 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -193,6 +193,25 @@ public function init(): void parent::init(); } + /** + * @inheritdoc + * @since 7.1.0 + */ + public static function gqlScopesByContext(mixed $context): array + { + /** @var FieldLayout $context */ + return ['ShopifyProduct']; + } + + /** + * @return string + * @since 7.1.0 + */ + public function getGqlTypeName(): string + { + return 'ShopifyProduct'; + } + /** * @inheritdoc */ diff --git a/src/elements/db/ProductQuery.php b/src/elements/db/ProductQuery.php index 5be84cd..abf7e06 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 new file mode 100644 index 0000000..21bfde8 --- /dev/null +++ b/src/gql/arguments/elements/Product.php @@ -0,0 +1,80 @@ + + * @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.', + ], + ]); + } + + /** + * @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..a265905 --- /dev/null +++ b/src/gql/interfaces/elements/Product.php @@ -0,0 +1,155 @@ + + * @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.', + ], + '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.', + ], + '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' => JsonType::getType(), + 'description' => 'The product’s variants in Shopify.', + ], + 'data' => [ + 'name' => 'data', + 'type' => JsonType::getType(), + 'description' => 'The product’s synced data from Shopify.', + ], + ]), 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..6b62bde --- /dev/null +++ b/src/gql/resolvers/elements/Product.php @@ -0,0 +1,63 @@ + + * @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 []; + } + + $query->withAll(); + + return $query; + } +} diff --git a/src/gql/types/JsonType.php b/src/gql/types/JsonType.php new file mode 100644 index 0000000..9e5335c --- /dev/null +++ b/src/gql/types/JsonType.php @@ -0,0 +1,72 @@ + + * @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/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..7aa3446 --- /dev/null +++ b/src/gql/types/generators/ProductType.php @@ -0,0 +1,53 @@ + + * @since 7.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 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.',