commercetools-8.x-1.2-alpha1/src/CommercetoolsProducts.php
src/CommercetoolsProducts.php
<?php
namespace Drupal\commercetools;
use Drupal\commercetools\Exception\CommercetoolsResourceNotFoundException;
use Drupal\Core\Config\ConfigFactoryInterface;
use GraphQL\Actions\Query;
use GraphQL\Entities\Node;
use GraphQL\Entities\Variable;
/**
* The commercetools products service.
*/
class CommercetoolsProducts {
/**
* Translatable product fields.
*/
const FIELDS_TRANSLATABLE_PRODUCT = [
'name',
'slug',
'description',
'metaTitle',
'metaDescription',
'metaKeywords',
];
const MISSING_SLUG_VALUE = 'broken-path';
/**
* CommercetoolsGraphQLService constructor.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* The config factory service.
* @param \Drupal\commercetools\CommercetoolsService $ct
* The Commercetools service.
* @param \Drupal\commercetools\CommercetoolsApiServiceInterface $ctApi
* The Commercetools API service.
*/
public function __construct(
protected ConfigFactoryInterface $configFactory,
protected CommercetoolsService $ct,
protected CommercetoolsApiServiceInterface $ctApi,
) {
}
/**
* Retrieves products based on the provided parameters.
*
* @param array $args
* An array with the query arguments:
* - sorts: An associative array of sort options to apply to the query.
* - queryFilters: An associative array of filters to apply to the query.
* - facets: An associative array of facets to add to the query.
* - offset: The number of products to skip before starting to collect the
* result set. Defaults to 0.
* - limit: The maximum number of products to return. Defaults to 10.
* @param bool|null $includeDetails
* Whether to include detailed product information. Defaults to NULL.
*
* @return \Drupal\commercetools\CacheableCommercetoolsResponse
* The response containing the product data.
*/
public function getProducts(array $args = [], ?bool $includeDetails = NULL) {
$query = $this->getProductsSearchQuery();
$this->addProductFields(
// @todo It overwrites the 'results' Node, rework to get the current one.
$query->results([]),
includeDetails: $includeDetails,
);
if (isset($args['facets'])) {
$this->addFacets($query);
}
$configLocale = $this->configFactory
->get(CommercetoolsLocalization::CONFIGURATION_NAME);
$localeLanguage = $configLocale->get(CommercetoolsLocalization::CONFIG_LANGUAGE);
$localeStore = $configLocale->get(CommercetoolsLocalization::CONFIG_STORE);
$variables = array_filter([
'filters' => $args['filters'] ?? [],
'queryFilters' => $args['queryFilters'] ?? [],
'text' => $args['text'] ?? NULL,
'locale' => empty($args['text']) ? NULL : ($args['locale'] ?? $localeLanguage),
'facets' => isset($args['facets']) ? array_map(function ($facet) {
return $facet['graphql'];
}, $args['facets']) : NULL,
'where' => isset($args['where']) ? $this->getProductsWhere($args['where']) : NULL,
'limit' => $args['limit'] ?? 10,
'offset' => $args['offset'] ?? 0,
'sort' => $args['sort'] ?? NULL,
'sorts' => $args['sorts'] ?? NULL,
'storeProjection' => $localeStore ?: NULL,
]);
$response = $this->ctApi->executeGraphQlOperation($query, $variables);
$result = $response->getData();
$productDataList = $result['productProjectionSearch'] ?? ['results' => []];
foreach ($productDataList['results'] as &$productData) {
$productData = $this->productDataToProduct($productData);
}
if (isset($productDataList['facets'])) {
foreach ($productDataList['facets'] as &$facet) {
$configKey = array_search($facet['facet'], array_column($args['facets'], 'path'));
$facet['label'] = $args['facets'][$configKey]['label'];
}
}
return $this->ctApi->prepareResponse($productDataList, $response->getCacheableMetadata());
}
/**
* Add facets to the query.
*/
public function addFacets(Query $query) {
$termsNode = new Node('terms');
$termsNode->use('term', 'productCount');
// Add range facets (for price ranges).
$rangesNode = new Node('ranges');
$rangesNodeType = $rangesNode->use('type');
$rangesNodeType->on('RangeCountDouble')->use('min', 'max');
$facetsQuery = $query->facets([]);
$facetsQuery->use('facet');
$facetsQueryValue = $facetsQuery->value([]);
$facetsQueryValue->on('TermsFacetResult')->use($termsNode);
$facetsQueryValue->on('RangeFacetResult')->use($rangesNode);
}
/**
* Constructs a GraphQL query for retrieving products.
*
* @return \GraphQL\Actions\Query
* The constructed GraphQL query.
*/
public function getProductsQuery() {
$arguments = [
'limit' => new Variable('limit', 'Int'),
'offset' => new Variable('offset', 'Int'),
'where' => new Variable('where', 'String'),
'sort' => new Variable('sort', '[String!]'),
'skus' => new Variable('skus', '[String!]'),
];
$query = new Query('products', $arguments);
$query->use('total');
$query->results([])->use('id');
return $query;
}
/**
* Constructs a GraphQL query for the search products.
*
* @return \GraphQL\Actions\Query
* The constructed GraphQL query.
*/
public function getProductsSearchQuery() {
$arguments = [
'limit' => new Variable('limit', 'Int'),
'offset' => new Variable('offset', 'Int'),
'sorts' => new Variable('sorts', '[String!]'),
'facets' => new Variable('facets', '[SearchFacetInput!]'),
'filters' => new Variable('filters', '[SearchFilterInput!]'),
'queryFilters' => new Variable('queryFilters', '[SearchFilterInput!]'),
'text' => new Variable('text', 'String'),
'locale' => new Variable('locale', 'Locale'),
'storeProjection' => new Variable('storeProjection', 'String'),
];
$query = new Query('productProjectionSearch', $arguments);
$query->use('total');
$query->results([])->use('id');
return $query;
}
/**
* Retrieves a product by its slug.
*
* @param string $slug
* The slug of the product.
*
* @return \Drupal\commercetools\CacheableCommercetoolsResponse
* The response containing the product data.
*/
public function getProductBySlug(
string $slug,
) {
$filter = [
'slug' => $slug,
];
$query = $this->getProductsQuery();
$variables = [
'limit' => 1,
'where' => $this->getProductsWhere($filter),
];
$this->addProductFields(
node: $query->results([]),
includeDetails: TRUE,
includeVariants: TRUE,
);
$resultCacheable = $this->ctApi->executeGraphQlOperation($query, $variables);
$result = $resultCacheable->getData();
if (count($result['products']['results']) === 0) {
throw new CommercetoolsResourceNotFoundException($slug);
}
$productData = $result['products']['results'][0];
$productDetails = $this->productDataToProduct($productData);
return $this->ctApi->prepareResponse($productDetails, $resultCacheable->getCacheableMetadata());
}
/**
* Converts the raw product data response to a simplified product array.
*
* @param array $productData
* The product data array.
*
* @return array
* The converted product array.
*/
protected function productDataToProduct(array $productData): array {
$product = $productData['masterData']['current'] ?? $productData;
$product['id'] = $productData['id'];
$product['type'] = $productData['productType'];
$product['masterVariant'] = $this->variantDataToVariant($product['masterVariant'], $product['type']['key']);
$product += $product['masterVariant'];
if (isset($product['variants'])) {
foreach ($product['variants'] as &$productDataVariant) {
$productDataVariant = $this->variantDataToVariant($productDataVariant, $product['type']['key']);
}
}
return $product;
}
/**
* Converts the raw variant data response to a simplified variant array.
*
* @param array $variant
* The variant data variant array.
* @param string $productType
* The product type.
*
* @return array
* The converted variant array.
*/
public function variantDataToVariant(array $variant, string $productType): array {
if ($variant['price']) {
$price = $this->ct->formatPrice($variant['price']['value']);
if (isset($variant['price']['discounted'])) {
$price['discounted'] = $this->ct->formatPrice($variant['price']['discounted']['value']);
}
$variant['price'] = $price;
}
if (isset($variant['availability'])) {
$variant['availability'] = $variant['availability']['noChannel']['availableQuantity'] ?? 0;
}
$variant['attributes'] = $this->attributesDataToAttributes($variant['attributesRaw'], $productType);
unset($variant['attributesRaw']);
return $variant;
}
/**
* Converts the attributes data response to a simplified attribute array.
*
* @param array $attrs
* The attributes data attributes array.
* @param string $productType
* The product type.
*
* @return array
* The converted attributes array.
*/
protected function attributesDataToAttributes(array $attrs, string $productType): array {
$productTypesDefinition = $this->ct->getProductsTypes();
$productTypeDefinition = $productTypesDefinition[$productType] ?? [];
$variantAttrs = [];
foreach ($attrs as $attr) {
if (empty($productTypeDefinition['attributeDefinitions'][$attr['name']])) {
continue;
}
$attrDefinition = $productTypeDefinition['attributeDefinitions'][$attr['name']];
switch ($attrDefinition['type']) {
case 'enum':
$attr['labelValue'] = $attr['value']['label'];
$attr['value'] = $attr['value']['key'];
break;
case 'lenum':
$attr['labelValue'] = $this->ct->getTranslationValue($attr['value']['label']);
$attr['value'] = $attr['value']['key'];
break;
case 'ltext':
$attr['value'] = $attr['labelValue'] = $this->ct->getTranslationValue($attr['value']);
break;
default:
$attr['labelValue'] = $attr['value'];
break;
}
$attr['label'] = $attrDefinition['label'];
$variantAttrs[$attr['name']] = $attr;
}
return $variantAttrs;
}
/**
* Adds product fields to a GraphQL node.
*
* @param \GraphQL\Entities\Node $node
* The GraphQL node.
* @param bool|null $includeDetails
* Whether to include detailed product information. Defaults to NULL.
* @param bool|null $includeVariants
* Whether to include product variants. Defaults to NULL.
* @param string|null $variantSku
* The SKU of the variant to include. Defaults to NULL.
* @param array|null $extraFields
* Additional fields to include. Defaults to NULL.
*
* @return \GraphQL\Entities\Node
* The modified GraphQL node.
*/
protected function addProductFields(
Node $node,
?bool $includeDetails = NULL,
?bool $includeVariants = NULL,
?string $variantSku = NULL,
?array $extraFields = NULL,
) {
$product = $node->getRootNode()->getName() === 'products' ? $node->masterData([])->current([]) : $node;
$node->use('id');
$node->productType([])->use('id', 'key', 'name');
$this->addProductCommonFields($product);
if ($includeDetails) {
$this->addProductDetailsFields($product);
}
if ($variantSku) {
$productVariant = $product->variant(['sku' => $variantSku]);
$this->addProductVariantFields($productVariant);
}
else {
$productVariant = $product->masterVariant([]);
}
$this->addProductVariantFields($productVariant);
if ($includeVariants) {
$productVariants = $product->variants([]);
$this->addProductVariantFields($productVariants);
}
return $node;
}
/**
* Adds common fields to a product node.
*
* @param \GraphQL\Entities\Node $product
* The product node to which the fields will be added.
*
* @return \GraphQL\Entities\Node
* The product node with the added fields.
*/
protected function addProductCommonFields(Node $product): Node {
$this->ct->addFieldsToNode($product, [
'slug',
'name',
], self::FIELDS_TRANSLATABLE_PRODUCT);
return $product;
}
/**
* Adds detailed fields to a product data node.
*
* @param \GraphQL\Entities\Node $productData
* The product data node to which the fields will be added.
*
* @return \GraphQL\Entities\Node
* The product data node with the added fields.
*/
protected function addProductDetailsFields(Node $productData): Node {
$this->ct->addFieldsToNode($productData, [
'description',
'metaTitle',
'metaDescription',
'metaKeywords',
'skus',
], self::FIELDS_TRANSLATABLE_PRODUCT);
return $productData;
}
/**
* Adds variant fields to a product variant node.
*
* @param \GraphQL\Entities\Node $productVariant
* The product variant node to which the fields will be added.
*
* @return \GraphQL\Entities\Node
* The product variant node with the added fields.
*/
public function addProductVariantFields(Node $productVariant): Node {
$productVariant->use(
'id',
'sku',
);
$productVariant->images([])->use('url', 'label');
$this->addAttributes($productVariant);
$this->ct->addPriceNode($productVariant, TRUE);
$this->addAvailabilityField($productVariant);
return $productVariant;
}
/**
* Adds an availability field to a product variant node.
*
* @param \GraphQL\Entities\Node $productVariant
* The product variant node to which the price field will be added.
*/
protected function addAvailabilityField(Node $productVariant): Node {
$productVariant->availability([])->noChannel([])->use('availableQuantity');
return $productVariant;
}
/**
* Adds an attributesRaw field to a product variant node.
*
* @param \GraphQL\Entities\Node $productVariant
* The product variant node to which the price field will be added.
*/
protected function addAttributes(Node $productVariant): Node {
$includeAttributes = [];
foreach ($this->ct->getEnabledAttributes() as $attributes) {
foreach ($attributes as $attribute) {
$includeAttributes[] = $attribute['name'];
}
}
$productVariant->attributesRaw(['includeNames' => $includeAttributes])->use('name', 'value');
return $productVariant;
}
/**
* Constructs a where clause string for a product query.
*
* @param array $filter
* An associative array of filters to apply to the product query.
*
* @return string
* The constructed where clause string.
*/
protected function getProductsWhere(array $filter): string {
$where = [
'masterData' => [
'current' => [],
],
];
foreach ($filter as $name => $value) {
$where['masterData']['current'][$name] = $this->ct->getWhereValue($name, $value, self::FIELDS_TRANSLATABLE_PRODUCT);
}
return CommercetoolsService::whereToString($where);
}
}
