+ helper(\Magento\Framework\Json\Helper\Data::class)->jsonDecode($block->getWidgetOptionsJson());
+ $widgetOptions = $this->helper(\Magento\Framework\Json\Helper\Data::class)->jsonEncode($widget['productListToolbarForm']);
+ ?>
+
isExpanded()) :?>
getTemplateFile('Magento_Catalog::product/list/toolbar/viewmode.phtml')) ?>
diff --git a/app/code/Magento/Catalog/view/frontend/web/js/catalog-add-to-cart.js b/app/code/Magento/Catalog/view/frontend/web/js/catalog-add-to-cart.js
index d74105fe531e4..382b4ef98532b 100644
--- a/app/code/Magento/Catalog/view/frontend/web/js/catalog-add-to-cart.js
+++ b/app/code/Magento/Catalog/view/frontend/web/js/catalog-add-to-cart.js
@@ -135,7 +135,9 @@ define([
// trigger global event, so other modules will be able add parameters to redirect url
$('body').trigger('catalogCategoryAddToCartRedirect', eventData);
- if (eventData.redirectParameters.length > 0) {
+ if (eventData.redirectParameters.length > 0 &&
+ window.location.href.split(/[?#]/)[0] === res.backUrl
+ ) {
parameters = res.backUrl.split('#');
parameters.push(eventData.redirectParameters.join('&'));
res.backUrl = parameters.join('#');
diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/AttributeQuery.php b/app/code/Magento/CatalogGraphQl/DataProvider/AttributeQuery.php
new file mode 100644
index 0000000000000..b0f085932bb8e
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/DataProvider/AttributeQuery.php
@@ -0,0 +1,354 @@
+resourceConnection = $resourceConnection;
+ $this->metadataPool = $metadataPool;
+ $this->entityType = $entityType;
+ $this->linkedAttributes = $linkedAttributes;
+ $this->eavConfig = $eavConfig;
+ }
+
+ /**
+ * Form and return query to get eav entity $attributes for given $entityIds.
+ *
+ * If eav entities were not found, then data is fetching from $entityTableName.
+ *
+ * @param array $entityIds
+ * @param array $attributes
+ * @param int $storeId
+ * @return Select
+ * @throws \Zend_Db_Select_Exception
+ * @throws \Exception
+ */
+ public function getQuery(array $entityIds, array $attributes, int $storeId): Select
+ {
+ /** @var \Magento\Framework\EntityManager\EntityMetadataInterface $metadata */
+ $metadata = $this->metadataPool->getMetadata($this->entityType);
+ $entityTableName = $metadata->getEntityTable();
+
+ /** @var \Magento\Framework\DB\Adapter\AdapterInterface $connection */
+ $connection = $this->resourceConnection->getConnection();
+ $entityTableAttributes = \array_keys($connection->describeTable($entityTableName));
+
+ $attributeMetadataTable = $this->resourceConnection->getTableName('eav_attribute');
+ $eavAttributes = $this->getEavAttributeCodes($attributes, $entityTableAttributes);
+ $entityTableAttributes = \array_intersect($attributes, $entityTableAttributes);
+
+ $eavAttributesMetaData = $this->getAttributesMetaData($connection, $attributeMetadataTable, $eavAttributes);
+
+ if ($eavAttributesMetaData) {
+ $select = $this->getEavAttributes(
+ $connection,
+ $metadata,
+ $entityTableAttributes,
+ $entityIds,
+ $eavAttributesMetaData,
+ $entityTableName,
+ $storeId
+ );
+ } else {
+ $select = $this->getAttributesFromEntityTable(
+ $connection,
+ $entityTableAttributes,
+ $entityIds,
+ $entityTableName
+ );
+ }
+
+ return $select;
+ }
+
+ /**
+ * Form and return query to get entity $entityTableAttributes for given $entityIds
+ *
+ * @param \Magento\Framework\DB\Adapter\AdapterInterface $connection
+ * @param array $entityTableAttributes
+ * @param array $entityIds
+ * @param string $entityTableName
+ * @return Select
+ */
+ private function getAttributesFromEntityTable(
+ \Magento\Framework\DB\Adapter\AdapterInterface $connection,
+ array $entityTableAttributes,
+ array $entityIds,
+ string $entityTableName
+ ): Select {
+ $select = $connection->select()
+ ->from(['e' => $entityTableName], $entityTableAttributes)
+ ->where('e.entity_id IN (?)', $entityIds);
+
+ return $select;
+ }
+
+ /**
+ * Return ids of eav attributes by $eavAttributeCodes.
+ *
+ * @param \Magento\Framework\DB\Adapter\AdapterInterface $connection
+ * @param string $attributeMetadataTable
+ * @param array $eavAttributeCodes
+ * @return array
+ */
+ private function getAttributesMetaData(
+ \Magento\Framework\DB\Adapter\AdapterInterface $connection,
+ string $attributeMetadataTable,
+ array $eavAttributeCodes
+ ): array {
+ $eavAttributeIdsSelect = $connection->select()
+ ->from(['a' => $attributeMetadataTable], ['attribute_id', 'backend_type', 'attribute_code'])
+ ->where('a.attribute_code IN (?)', $eavAttributeCodes)
+ ->where('a.entity_type_id = ?', $this->getEntityTypeId());
+
+ return $connection->fetchAssoc($eavAttributeIdsSelect);
+ }
+
+ /**
+ * Form and return query to get eav entity $attributes for given $entityIds.
+ *
+ * @param \Magento\Framework\DB\Adapter\AdapterInterface $connection
+ * @param \Magento\Framework\EntityManager\EntityMetadataInterface $metadata
+ * @param array $entityTableAttributes
+ * @param array $entityIds
+ * @param array $eavAttributesMetaData
+ * @param string $entityTableName
+ * @param int $storeId
+ * @return Select
+ * @throws \Zend_Db_Select_Exception
+ */
+ private function getEavAttributes(
+ \Magento\Framework\DB\Adapter\AdapterInterface $connection,
+ \Magento\Framework\EntityManager\EntityMetadataInterface $metadata,
+ array $entityTableAttributes,
+ array $entityIds,
+ array $eavAttributesMetaData,
+ string $entityTableName,
+ int $storeId
+ ): Select {
+ $selects = [];
+ $attributeValueExpression = $connection->getCheckSql(
+ $connection->getIfNullSql('store_eav.value_id', -1) . ' > 0',
+ 'store_eav.value',
+ 'eav.value'
+ );
+ $linkField = $metadata->getLinkField();
+ $attributesPerTable = $this->getAttributeCodeTables($entityTableName, $eavAttributesMetaData);
+ foreach ($attributesPerTable as $attributeTable => $eavAttributes) {
+ $attributeCodeExpression = $this->buildAttributeCodeExpression($eavAttributes);
+
+ $selects[] = $connection->select()
+ ->from(['e' => $entityTableName], $entityTableAttributes)
+ ->joinLeft(
+ ['eav' => $this->resourceConnection->getTableName($attributeTable)],
+ \sprintf('e.%1$s = eav.%1$s', $linkField) .
+ $connection->quoteInto(' AND eav.attribute_id IN (?)', \array_keys($eavAttributesMetaData)) .
+ $connection->quoteInto(' AND eav.store_id = ?', \Magento\Store\Model\Store::DEFAULT_STORE_ID),
+ []
+ )
+ ->joinLeft(
+ ['store_eav' => $this->resourceConnection->getTableName($attributeTable)],
+ \sprintf(
+ 'e.%1$s = store_eav.%1$s AND store_eav.attribute_id = ' .
+ 'eav.attribute_id and store_eav.store_id = %2$d',
+ $linkField,
+ $storeId
+ ),
+ []
+ )
+ ->where('e.entity_id IN (?)', $entityIds)
+ ->columns(
+ [
+ 'attribute_code' => $attributeCodeExpression,
+ 'value' => $attributeValueExpression
+ ]
+ );
+ }
+
+ return $connection->select()->union($selects, Select::SQL_UNION_ALL);
+ }
+
+ /**
+ * Build expression for attribute code field.
+ *
+ * An example:
+ *
+ * ```
+ * CASE
+ * WHEN eav.attribute_id = '73' THEN 'name'
+ * WHEN eav.attribute_id = '121' THEN 'url_key'
+ * END
+ * ```
+ *
+ * @param array $eavAttributes
+ * @return \Zend_Db_Expr
+ */
+ private function buildAttributeCodeExpression(array $eavAttributes): \Zend_Db_Expr
+ {
+ $dbConnection = $this->resourceConnection->getConnection();
+ $expressionParts = ['CASE'];
+
+ foreach ($eavAttributes as $attribute) {
+ $expressionParts[]=
+ $dbConnection->quoteInto('WHEN eav.attribute_id = ?', $attribute['attribute_id'], \Zend_Db::INT_TYPE) .
+ $dbConnection->quoteInto(' THEN ?', $attribute['attribute_code'], 'string');
+ }
+
+ $expressionParts[]= 'END';
+
+ return new \Zend_Db_Expr(implode(' ', $expressionParts));
+ }
+
+ /**
+ * Get list of attribute tables.
+ *
+ * Returns result in the following format: *
+ * ```
+ * $attributeAttributeCodeTables = [
+ * 'm2_catalog_product_entity_varchar' =>
+ * '45' => [
+ * 'attribute_id' => 45,
+ * 'backend_type' => 'varchar',
+ * 'name' => attribute_code,
+ * ]
+ * ]
+ * ];
+ * ```
+ *
+ * @param string $entityTable
+ * @param array $eavAttributesMetaData
+ * @return array
+ */
+ private function getAttributeCodeTables($entityTable, $eavAttributesMetaData): array
+ {
+ $attributeAttributeCodeTables = [];
+ $metaTypes = \array_unique(\array_column($eavAttributesMetaData, 'backend_type'));
+
+ foreach ($metaTypes as $type) {
+ if (\in_array($type, self::SUPPORTED_BACKEND_TYPES, true)) {
+ $tableName = \sprintf('%s_%s', $entityTable, $type);
+ $attributeAttributeCodeTables[$tableName] = array_filter(
+ $eavAttributesMetaData,
+ function ($attribute) use ($type) {
+ return $attribute['backend_type'] === $type;
+ }
+ );
+ }
+ }
+
+ return $attributeAttributeCodeTables;
+ }
+
+ /**
+ * Get EAV attribute codes
+ * Remove attributes from entity table and attributes from exclude list
+ * Add linked attributes to output
+ *
+ * @param array $attributes
+ * @param array $entityTableAttributes
+ * @return array
+ */
+ private function getEavAttributeCodes($attributes, $entityTableAttributes): array
+ {
+ $attributes = \array_diff($attributes, $entityTableAttributes);
+ $unusedAttributeList = [];
+ $newAttributes = [];
+ foreach ($this->linkedAttributes as $attribute => $linkedAttributes) {
+ if (null === $linkedAttributes) {
+ $unusedAttributeList[] = $attribute;
+ } elseif (\is_array($linkedAttributes) && \in_array($attribute, $attributes, true)) {
+ $newAttributes[] = $linkedAttributes;
+ }
+ }
+ $attributes = \array_diff($attributes, $unusedAttributeList);
+
+ return \array_unique(\array_merge($attributes, ...$newAttributes));
+ }
+
+ /**
+ * Retrieve entity type id
+ *
+ * @return int
+ * @throws \Exception
+ */
+ private function getEntityTypeId(): int
+ {
+ if (!isset($this->entityTypeIdMap[$this->entityType])) {
+ $this->entityTypeIdMap[$this->entityType] = (int)$this->eavConfig->getEntityType(
+ $this->metadataPool->getMetadata($this->entityType)->getEavEntityType()
+ )->getId();
+ }
+
+ return $this->entityTypeIdMap[$this->entityType];
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Category/Query/CategoryAttributeQuery.php b/app/code/Magento/CatalogGraphQl/DataProvider/Category/Query/CategoryAttributeQuery.php
new file mode 100644
index 0000000000000..e3dfa38c78258
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/DataProvider/Category/Query/CategoryAttributeQuery.php
@@ -0,0 +1,60 @@
+attributeQueryFactory = $attributeQueryFactory;
+ }
+
+ /**
+ * Form and return query to get eav attributes for given categories
+ *
+ * @param array $categoryIds
+ * @param array $categoryAttributes
+ * @param int $storeId
+ * @return Select
+ * @throws \Zend_Db_Select_Exception
+ */
+ public function getQuery(array $categoryIds, array $categoryAttributes, int $storeId): Select
+ {
+ $categoryAttributes = \array_merge($categoryAttributes, self::$requiredAttributes);
+
+ $attributeQuery = $this->attributeQueryFactory->create(
+ [
+ 'entityType' => CategoryInterface::class
+ ]
+ );
+
+ return $attributeQuery->getQuery($categoryIds, $categoryAttributes, $storeId);
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/CategoryAttributesMapper.php b/app/code/Magento/CatalogGraphQl/DataProvider/CategoryAttributesMapper.php
new file mode 100644
index 0000000000000..ea3c0b608d212
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/DataProvider/CategoryAttributesMapper.php
@@ -0,0 +1,117 @@
+graphqlConfig = $graphqlConfig;
+ }
+
+ /**
+ * Returns attribute values for given attribute codes.
+ *
+ * @param array $fetchResult
+ * @return array
+ */
+ public function getAttributesValues(array $fetchResult): array
+ {
+ $attributes = [];
+
+ foreach ($fetchResult as $row) {
+ if (!isset($attributes[$row['entity_id']])) {
+ $attributes[$row['entity_id']] = $row;
+ //TODO: do we need to introduce field mapping?
+ $attributes[$row['entity_id']]['id'] = $row['entity_id'];
+ }
+ if (isset($row['attribute_code'])) {
+ $attributes[$row['entity_id']][$row['attribute_code']] = $row['value'];
+ }
+ }
+
+ return $this->formatAttributes($attributes);
+ }
+
+ /**
+ * Format attributes that should be converted to array type
+ *
+ * @param array $attributes
+ * @return array
+ */
+ private function formatAttributes(array $attributes): array
+ {
+ $arrayTypeAttributes = $this->getFieldsOfArrayType();
+
+ return $arrayTypeAttributes
+ ? array_map(
+ function ($data) use ($arrayTypeAttributes) {
+ foreach ($arrayTypeAttributes as $attributeCode) {
+ $data[$attributeCode] = $this->valueToArray($data[$attributeCode] ?? null);
+ }
+ return $data;
+ },
+ $attributes
+ )
+ : $attributes;
+ }
+
+ /**
+ * Cast string to array
+ *
+ * @param string|null $value
+ * @return array
+ */
+ private function valueToArray($value): array
+ {
+ return $value ? \explode(',', $value) : [];
+ }
+
+ /**
+ * Get fields that should be converted to array type
+ *
+ * @return array
+ */
+ private function getFieldsOfArrayType(): array
+ {
+ $categoryTreeSchema = $this->graphqlConfig->getConfigElement('CategoryTree');
+ if (!$categoryTreeSchema instanceof Type) {
+ throw new \LogicException('CategoryTree type not defined in schema.');
+ }
+
+ $fields = [];
+ foreach ($categoryTreeSchema->getInterfaces() as $interface) {
+ /** @var InterfaceType $configElement */
+ $configElement = $this->graphqlConfig->getConfigElement($interface['interface']);
+
+ foreach ($configElement->getFields() as $field) {
+ if ($field->isList()) {
+ $fields[] = $field->getName();
+ }
+ }
+ }
+
+ return $fields;
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/AttributeOptionProvider.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/AttributeOptionProvider.php
new file mode 100644
index 0000000000000..7781473128754
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/AttributeOptionProvider.php
@@ -0,0 +1,107 @@
+ [
+ * attribute_code => code,
+ * attribute_label => attribute label,
+ * option_label => option label,
+ * options => [option_id => 'option label', ...],
+ * ]
+ * ...
+ * ]
+ */
+class AttributeOptionProvider
+{
+ /**
+ * @var ResourceConnection
+ */
+ private $resourceConnection;
+
+ /**
+ * @param ResourceConnection $resourceConnection
+ */
+ public function __construct(ResourceConnection $resourceConnection)
+ {
+ $this->resourceConnection = $resourceConnection;
+ }
+
+ /**
+ * Get option data. Return list of attributes with option data
+ *
+ * @param array $optionIds
+ * @return array
+ * @throws \Zend_Db_Statement_Exception
+ */
+ public function getOptions(array $optionIds): array
+ {
+ if (!$optionIds) {
+ return [];
+ }
+
+ $connection = $this->resourceConnection->getConnection();
+ $select = $connection->select()
+ ->from(
+ ['a' => $this->resourceConnection->getTableName('eav_attribute')],
+ [
+ 'attribute_id' => 'a.attribute_id',
+ 'attribute_code' => 'a.attribute_code',
+ 'attribute_label' => 'a.frontend_label',
+ ]
+ )
+ ->joinInner(
+ ['options' => $this->resourceConnection->getTableName('eav_attribute_option')],
+ 'a.attribute_id = options.attribute_id',
+ []
+ )
+ ->joinInner(
+ ['option_value' => $this->resourceConnection->getTableName('eav_attribute_option_value')],
+ 'options.option_id = option_value.option_id',
+ [
+ 'option_label' => 'option_value.value',
+ 'option_id' => 'option_value.option_id',
+ ]
+ )
+ ->where('option_value.option_id IN (?)', $optionIds);
+
+ return $this->formatResult($select);
+ }
+
+ /**
+ * Format result
+ *
+ * @param \Magento\Framework\DB\Select $select
+ * @return array
+ * @throws \Zend_Db_Statement_Exception
+ */
+ private function formatResult(\Magento\Framework\DB\Select $select): array
+ {
+ $statement = $this->resourceConnection->getConnection()->query($select);
+
+ $result = [];
+ while ($option = $statement->fetch()) {
+ if (!isset($result[$option['attribute_code']])) {
+ $result[$option['attribute_code']] = [
+ 'attribute_id' => $option['attribute_id'],
+ 'attribute_code' => $option['attribute_code'],
+ 'attribute_label' => $option['attribute_label'],
+ 'options' => [],
+ ];
+ }
+ $result[$option['attribute_code']]['options'][$option['option_id']] = $option['option_label'];
+ }
+
+ return $result;
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Attribute.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Attribute.php
new file mode 100644
index 0000000000000..b70c9f6165fc6
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Attribute.php
@@ -0,0 +1,157 @@
+attributeOptionProvider = $attributeOptionProvider;
+ $this->layerFormatter = $layerFormatter;
+ $this->bucketNameFilter = \array_merge($this->bucketNameFilter, $bucketNameFilter);
+ }
+
+ /**
+ * @inheritdoc
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @throws \Zend_Db_Statement_Exception
+ */
+ public function build(AggregationInterface $aggregation, ?int $storeId): array
+ {
+ $attributeOptions = $this->getAttributeOptions($aggregation);
+
+ // build layer per attribute
+ $result = [];
+ foreach ($this->getAttributeBuckets($aggregation) as $bucket) {
+ $bucketName = $bucket->getName();
+ $attributeCode = \preg_replace('~_bucket$~', '', $bucketName);
+ $attribute = $attributeOptions[$attributeCode] ?? [];
+
+ $result[$bucketName] = $this->layerFormatter->buildLayer(
+ $attribute['attribute_label'] ?? $bucketName,
+ \count($bucket->getValues()),
+ $attribute['attribute_code'] ?? $bucketName
+ );
+
+ foreach ($bucket->getValues() as $value) {
+ $metrics = $value->getMetrics();
+ $result[$bucketName]['options'][] = $this->layerFormatter->buildItem(
+ $attribute['options'][$metrics['value']] ?? $metrics['value'],
+ $metrics['value'],
+ $metrics['count']
+ );
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get attribute buckets excluding specified bucket names
+ *
+ * @param AggregationInterface $aggregation
+ * @return \Generator|BucketInterface[]
+ */
+ private function getAttributeBuckets(AggregationInterface $aggregation)
+ {
+ foreach ($aggregation->getBuckets() as $bucket) {
+ if (\in_array($bucket->getName(), $this->bucketNameFilter, true)) {
+ continue;
+ }
+ if ($this->isBucketEmpty($bucket)) {
+ continue;
+ }
+ yield $bucket;
+ }
+ }
+
+ /**
+ * Check that bucket contains data
+ *
+ * @param BucketInterface|null $bucket
+ * @return bool
+ */
+ private function isBucketEmpty(?BucketInterface $bucket): bool
+ {
+ return null === $bucket || !$bucket->getValues();
+ }
+
+ /**
+ * Get list of attributes with options
+ *
+ * @param AggregationInterface $aggregation
+ * @return array
+ * @throws \Zend_Db_Statement_Exception
+ */
+ private function getAttributeOptions(AggregationInterface $aggregation): array
+ {
+ $attributeOptionIds = [];
+ foreach ($this->getAttributeBuckets($aggregation) as $bucket) {
+ $attributeOptionIds[] = \array_map(
+ function (AggregationValueInterface $value) {
+ return $value->getValue();
+ },
+ $bucket->getValues()
+ );
+ }
+
+ if (!$attributeOptionIds) {
+ return [];
+ }
+
+ return $this->attributeOptionProvider->getOptions(\array_merge(...$attributeOptionIds));
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Category.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Category.php
new file mode 100644
index 0000000000000..b0e67d72e25ba
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Category.php
@@ -0,0 +1,151 @@
+ [
+ 'request_name' => 'category_id',
+ 'label' => 'Category'
+ ],
+ ];
+
+ /**
+ * @var CategoryAttributeQuery
+ */
+ private $categoryAttributeQuery;
+
+ /**
+ * @var CategoryAttributesMapper
+ */
+ private $attributesMapper;
+
+ /**
+ * @var ResourceConnection
+ */
+ private $resourceConnection;
+
+ /**
+ * @var RootCategoryProvider
+ */
+ private $rootCategoryProvider;
+
+ /**
+ * @var LayerFormatter
+ */
+ private $layerFormatter;
+
+ /**
+ * @param CategoryAttributeQuery $categoryAttributeQuery
+ * @param CategoryAttributesMapper $attributesMapper
+ * @param RootCategoryProvider $rootCategoryProvider
+ * @param ResourceConnection $resourceConnection
+ * @param LayerFormatter $layerFormatter
+ */
+ public function __construct(
+ CategoryAttributeQuery $categoryAttributeQuery,
+ CategoryAttributesMapper $attributesMapper,
+ RootCategoryProvider $rootCategoryProvider,
+ ResourceConnection $resourceConnection,
+ LayerFormatter $layerFormatter
+ ) {
+ $this->categoryAttributeQuery = $categoryAttributeQuery;
+ $this->attributesMapper = $attributesMapper;
+ $this->resourceConnection = $resourceConnection;
+ $this->rootCategoryProvider = $rootCategoryProvider;
+ $this->layerFormatter = $layerFormatter;
+ }
+
+ /**
+ * @inheritdoc
+ * @throws \Magento\Framework\Exception\LocalizedException
+ * @throws \Zend_Db_Select_Exception
+ */
+ public function build(AggregationInterface $aggregation, ?int $storeId): array
+ {
+ $bucket = $aggregation->getBucket(self::CATEGORY_BUCKET);
+ if ($this->isBucketEmpty($bucket)) {
+ return [];
+ }
+
+ $categoryIds = \array_map(
+ function (AggregationValueInterface $value) {
+ return (int)$value->getValue();
+ },
+ $bucket->getValues()
+ );
+
+ $categoryIds = \array_diff($categoryIds, [$this->rootCategoryProvider->getRootCategory($storeId)]);
+ $categoryLabels = \array_column(
+ $this->attributesMapper->getAttributesValues(
+ $this->resourceConnection->getConnection()->fetchAll(
+ $this->categoryAttributeQuery->getQuery($categoryIds, ['name'], $storeId)
+ )
+ ),
+ 'name',
+ 'entity_id'
+ );
+
+ if (!$categoryLabels) {
+ return [];
+ }
+
+ $result = $this->layerFormatter->buildLayer(
+ self::$bucketMap[self::CATEGORY_BUCKET]['label'],
+ \count($categoryIds),
+ self::$bucketMap[self::CATEGORY_BUCKET]['request_name']
+ );
+
+ foreach ($bucket->getValues() as $value) {
+ $categoryId = $value->getValue();
+ if (!\in_array($categoryId, $categoryIds, true)) {
+ continue ;
+ }
+ $result['options'][] = $this->layerFormatter->buildItem(
+ $categoryLabels[$categoryId] ?? $categoryId,
+ $categoryId,
+ $value->getMetrics()['count']
+ );
+ }
+
+ return [$result];
+ }
+
+ /**
+ * Check that bucket contains data
+ *
+ * @param BucketInterface|null $bucket
+ * @return bool
+ */
+ private function isBucketEmpty(?BucketInterface $bucket): bool
+ {
+ return null === $bucket || !$bucket->getValues();
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Price.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Price.php
new file mode 100644
index 0000000000000..02b638edbdce8
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Builder/Price.php
@@ -0,0 +1,88 @@
+ [
+ 'request_name' => 'price',
+ 'label' => 'Price'
+ ],
+ ];
+
+ /**
+ * @param LayerFormatter $layerFormatter
+ */
+ public function __construct(
+ LayerFormatter $layerFormatter
+ ) {
+ $this->layerFormatter = $layerFormatter;
+ }
+
+ /**
+ * @inheritdoc
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ public function build(AggregationInterface $aggregation, ?int $storeId): array
+ {
+ $bucket = $aggregation->getBucket(self::PRICE_BUCKET);
+ if ($this->isBucketEmpty($bucket)) {
+ return [];
+ }
+
+ $result = $this->layerFormatter->buildLayer(
+ self::$bucketMap[self::PRICE_BUCKET]['label'],
+ \count($bucket->getValues()),
+ self::$bucketMap[self::PRICE_BUCKET]['request_name']
+ );
+
+ foreach ($bucket->getValues() as $value) {
+ $metrics = $value->getMetrics();
+ $result['options'][] = $this->layerFormatter->buildItem(
+ \str_replace('_', '-', $metrics['value']),
+ $metrics['value'],
+ $metrics['count']
+ );
+ }
+
+ return [$result];
+ }
+
+ /**
+ * Check that bucket contains data
+ *
+ * @param BucketInterface|null $bucket
+ * @return bool
+ */
+ private function isBucketEmpty(?BucketInterface $bucket): bool
+ {
+ return null === $bucket || !$bucket->getValues();
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Formatter/LayerFormatter.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Formatter/LayerFormatter.php
new file mode 100644
index 0000000000000..48a1265b10fc3
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/Formatter/LayerFormatter.php
@@ -0,0 +1,48 @@
+ $layerName,
+ 'count' => $itemsCount,
+ 'attribute_code' => $requestName
+ ];
+ }
+
+ /**
+ * Format layer item data
+ *
+ * @param string $label
+ * @param string|int $value
+ * @param string|int $count
+ * @return array
+ */
+ public function buildItem($label, $value, $count): array
+ {
+ return [
+ 'label' => $label,
+ 'value' => $value,
+ 'count' => $count,
+ ];
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/LayerBuilder.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/LayerBuilder.php
new file mode 100644
index 0000000000000..ff661236be62f
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/LayerBuilder.php
@@ -0,0 +1,43 @@
+builders = $builders;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function build(AggregationInterface $aggregation, ?int $storeId): array
+ {
+ $layers = [];
+ foreach ($this->builders as $builder) {
+ $layers[] = $builder->build($aggregation, $storeId);
+ }
+ $layers = \array_merge(...$layers);
+
+ return \array_filter($layers);
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/LayerBuilderInterface.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/LayerBuilderInterface.php
new file mode 100644
index 0000000000000..bd55bc6938b39
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/LayerBuilderInterface.php
@@ -0,0 +1,40 @@
+ 'layer name',
+ * 'filter_items_count' => 'filter items count',
+ * 'request_var' => 'filter name in request',
+ * 'filter_items' => [
+ * 'label' => 'item name',
+ * 'value_string' => 'item value, e.g. category ID',
+ * 'items_count' => 'product count',
+ * ],
+ * ],
+ * ...
+ * ];
+ */
+interface LayerBuilderInterface
+{
+ /**
+ * Build layer data
+ *
+ * @param AggregationInterface $aggregation
+ * @param int|null $storeId
+ * @return array [[{layer data}], ...]
+ */
+ public function build(AggregationInterface $aggregation, ?int $storeId): array;
+}
diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/RootCategoryProvider.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/RootCategoryProvider.php
new file mode 100644
index 0000000000000..4b8a4a31b3c35
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/LayeredNavigation/RootCategoryProvider.php
@@ -0,0 +1,55 @@
+resourceConnection = $resourceConnection;
+ }
+
+ /**
+ * Get root category for specified store id
+ *
+ * @param int $storeId
+ * @return int
+ */
+ public function getRootCategory(int $storeId): int
+ {
+ $connection = $this->resourceConnection->getConnection();
+
+ $select = $connection->select()
+ ->from(
+ ['store' => $this->resourceConnection->getTableName('store')],
+ []
+ )
+ ->join(
+ ['store_group' => $this->resourceConnection->getTableName('store_group')],
+ 'store.group_id = store_group.group_id',
+ ['root_category_id' => 'store_group.root_category_id']
+ )
+ ->where('store.store_id = ?', $storeId);
+
+ return (int)$connection->fetchOne($select);
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/DataProvider/Product/SearchCriteriaBuilder.php b/app/code/Magento/CatalogGraphQl/DataProvider/Product/SearchCriteriaBuilder.php
new file mode 100644
index 0000000000000..0e92bbbab4259
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/DataProvider/Product/SearchCriteriaBuilder.php
@@ -0,0 +1,208 @@
+scopeConfig = $scopeConfig;
+ $this->filterBuilder = $filterBuilder;
+ $this->filterGroupBuilder = $filterGroupBuilder;
+ $this->builder = $builder;
+ $this->visibility = $visibility;
+ $this->sortOrderBuilder = $sortOrderBuilder;
+ }
+
+ /**
+ * Build search criteria
+ *
+ * @param array $args
+ * @param bool $includeAggregation
+ * @return SearchCriteriaInterface
+ */
+ public function build(array $args, bool $includeAggregation): SearchCriteriaInterface
+ {
+ $searchCriteria = $this->builder->build('products', $args);
+ $isSearch = !empty($args['search']);
+ $this->updateRangeFilters($searchCriteria);
+
+ if ($includeAggregation) {
+ $this->preparePriceAggregation($searchCriteria);
+ $requestName = 'graphql_product_search_with_aggregation';
+ } else {
+ $requestName = 'graphql_product_search';
+ }
+ $searchCriteria->setRequestName($requestName);
+
+ if ($isSearch) {
+ $this->addFilter($searchCriteria, 'search_term', $args['search']);
+ }
+
+ if (!$searchCriteria->getSortOrders()) {
+ $this->addDefaultSortOrder($searchCriteria, $isSearch);
+ }
+
+ $this->addVisibilityFilter($searchCriteria, $isSearch, !empty($args['filter']));
+
+ $searchCriteria->setCurrentPage($args['currentPage']);
+ $searchCriteria->setPageSize($args['pageSize']);
+
+ return $searchCriteria;
+ }
+
+ /**
+ * Add filter by visibility
+ *
+ * @param SearchCriteriaInterface $searchCriteria
+ * @param bool $isSearch
+ * @param bool $isFilter
+ */
+ private function addVisibilityFilter(SearchCriteriaInterface $searchCriteria, bool $isSearch, bool $isFilter): void
+ {
+ if ($isFilter && $isSearch) {
+ // Index already contains products filtered by visibility: catalog, search, both
+ return ;
+ }
+ $visibilityIds = $isSearch
+ ? $this->visibility->getVisibleInSearchIds()
+ : $this->visibility->getVisibleInCatalogIds();
+
+ $this->addFilter($searchCriteria, 'visibility', $visibilityIds);
+ }
+
+ /**
+ * Prepare price aggregation algorithm
+ *
+ * @param SearchCriteriaInterface $searchCriteria
+ * @return void
+ */
+ private function preparePriceAggregation(SearchCriteriaInterface $searchCriteria): void
+ {
+ $priceRangeCalculation = $this->scopeConfig->getValue(
+ \Magento\Catalog\Model\Layer\Filter\Dynamic\AlgorithmFactory::XML_PATH_RANGE_CALCULATION,
+ \Magento\Store\Model\ScopeInterface::SCOPE_STORE
+ );
+ if ($priceRangeCalculation) {
+ $this->addFilter($searchCriteria, 'price_dynamic_algorithm', $priceRangeCalculation);
+ }
+ }
+
+ /**
+ * Add filter to search criteria
+ *
+ * @param SearchCriteriaInterface $searchCriteria
+ * @param string $field
+ * @param mixed $value
+ */
+ private function addFilter(SearchCriteriaInterface $searchCriteria, string $field, $value): void
+ {
+ $filter = $this->filterBuilder
+ ->setField($field)
+ ->setValue($value)
+ ->create();
+ $this->filterGroupBuilder->addFilter($filter);
+ $filterGroups = $searchCriteria->getFilterGroups();
+ $filterGroups[] = $this->filterGroupBuilder->create();
+ $searchCriteria->setFilterGroups($filterGroups);
+ }
+
+ /**
+ * Sort by relevance DESC by default
+ *
+ * @param SearchCriteriaInterface $searchCriteria
+ * @param bool $isSearch
+ */
+ private function addDefaultSortOrder(SearchCriteriaInterface $searchCriteria, $isSearch = false): void
+ {
+ $sortField = $isSearch ? 'relevance' : EavAttributeInterface::POSITION;
+ $sortDirection = $isSearch ? SortOrder::SORT_DESC : SortOrder::SORT_ASC;
+ $defaultSortOrder = $this->sortOrderBuilder
+ ->setField($sortField)
+ ->setDirection($sortDirection)
+ ->create();
+
+ $searchCriteria->setSortOrders([$defaultSortOrder]);
+ }
+
+ /**
+ * Format range filters so replacement works
+ *
+ * Range filter fields in search request must replace value like '%field.from%' or '%field.to%'
+ *
+ * @param SearchCriteriaInterface $searchCriteria
+ */
+ private function updateRangeFilters(SearchCriteriaInterface $searchCriteria): void
+ {
+ $filterGroups = $searchCriteria->getFilterGroups();
+ foreach ($filterGroups as $filterGroup) {
+ $filters = $filterGroup->getFilters();
+ foreach ($filters as $filter) {
+ if (in_array($filter->getConditionType(), ['from', 'to'])) {
+ $filter->setField($filter->getField() . '.' . $filter->getConditionType());
+ }
+ }
+ }
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/Model/AggregationOptionTypeResolver.php b/app/code/Magento/CatalogGraphQl/Model/AggregationOptionTypeResolver.php
new file mode 100644
index 0000000000000..3a532a1a6c760
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/Model/AggregationOptionTypeResolver.php
@@ -0,0 +1,29 @@
+typeResolvers = $typeResolvers;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function resolveType(array $data) : string
+ {
+ /** @var TypeResolverInterface $typeResolver */
+ foreach ($this->typeResolvers as $typeResolver) {
+ $resolvedType = $typeResolver->resolveType($data);
+ if ($resolvedType) {
+ return $resolvedType;
+ }
+ }
+ throw new GraphQlInputException(__('Cannot resolve aggregation option type'));
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/Model/Config/FilterAttributeReader.php b/app/code/Magento/CatalogGraphQl/Model/Config/FilterAttributeReader.php
new file mode 100644
index 0000000000000..4f3a88cc788df
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/Model/Config/FilterAttributeReader.php
@@ -0,0 +1,134 @@
+mapper = $mapper;
+ $this->collectionFactory = $collectionFactory;
+ $this->exactMatchAttributes = array_merge($this->exactMatchAttributes, $exactMatchAttributes);
+ }
+
+ /**
+ * Read configuration scope
+ *
+ * @param string|null $scope
+ * @return array
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ public function read($scope = null) : array
+ {
+ $typeNames = $this->mapper->getMappedTypes(self::ENTITY_TYPE);
+ $config = [];
+
+ foreach ($this->getAttributeCollection() as $attribute) {
+ $attributeCode = $attribute->getAttributeCode();
+
+ foreach ($typeNames as $typeName) {
+ $config[$typeName]['fields'][$attributeCode] = [
+ 'name' => $attributeCode,
+ 'type' => $this->getFilterType($attribute),
+ 'arguments' => [],
+ 'required' => false,
+ 'description' => sprintf('Attribute label: %s', $attribute->getDefaultFrontendLabel())
+ ];
+ }
+ }
+
+ return $config;
+ }
+
+ /**
+ * Map attribute type to filter type
+ *
+ * @param Attribute $attribute
+ * @return string
+ */
+ private function getFilterType(Attribute $attribute): string
+ {
+ if (in_array($attribute->getAttributeCode(), $this->exactMatchAttributes)) {
+ return self::FILTER_EQUAL_TYPE;
+ }
+
+ $filterTypeMap = [
+ 'price' => self::FILTER_RANGE_TYPE,
+ 'date' => self::FILTER_RANGE_TYPE,
+ 'select' => self::FILTER_EQUAL_TYPE,
+ 'multiselect' => self::FILTER_EQUAL_TYPE,
+ 'boolean' => self::FILTER_EQUAL_TYPE,
+ 'text' => self::FILTER_MATCH_TYPE,
+ 'textarea' => self::FILTER_MATCH_TYPE,
+ ];
+
+ return $filterTypeMap[$attribute->getFrontendInput()] ?? self::FILTER_MATCH_TYPE;
+ }
+
+ /**
+ * Create attribute collection
+ *
+ * @return Collection|\Magento\Catalog\Model\ResourceModel\Eav\Attribute[]
+ */
+ private function getAttributeCollection()
+ {
+ return $this->collectionFactory->create()
+ ->addHasOptionsFilter()
+ ->addIsSearchableFilter()
+ ->addDisplayInAdvancedSearchFilter();
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/Model/Config/SortAttributeReader.php b/app/code/Magento/CatalogGraphQl/Model/Config/SortAttributeReader.php
new file mode 100644
index 0000000000000..215b28be0579c
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/Model/Config/SortAttributeReader.php
@@ -0,0 +1,79 @@
+mapper = $mapper;
+ $this->attributesCollection = $attributesCollection;
+ }
+
+ /**
+ * Read configuration scope
+ *
+ * @param string|null $scope
+ * @return array
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ public function read($scope = null) : array
+ {
+ $map = $this->mapper->getMappedTypes(self::ENTITY_TYPE);
+ $config =[];
+ $attributes = $this->attributesCollection->addSearchableAttributeFilter()->addFilter('used_for_sort_by', 1);
+ /** @var \Magento\Catalog\Model\ResourceModel\Eav\Attribute $attribute */
+ foreach ($attributes as $attribute) {
+ $attributeCode = $attribute->getAttributeCode();
+ $attributeLabel = $attribute->getDefaultFrontendLabel();
+ foreach ($map as $type) {
+ $config[$type]['fields'][$attributeCode] = [
+ 'name' => $attributeCode,
+ 'type' => self::FIELD_TYPE,
+ 'arguments' => [],
+ 'required' => false,
+ 'description' => __('Attribute label: ') . $attributeLabel
+ ];
+ }
+ }
+
+ return $config;
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Aggregations.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Aggregations.php
new file mode 100644
index 0000000000000..47a1d1f977f9b
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Aggregations.php
@@ -0,0 +1,68 @@
+filtersDataProvider = $filtersDataProvider;
+ $this->layerBuilder = $layerBuilder;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function resolve(
+ Field $field,
+ $context,
+ ResolveInfo $info,
+ array $value = null,
+ array $args = null
+ ) {
+ if (!isset($value['layer_type']) || !isset($value['search_result'])) {
+ return null;
+ }
+
+ $aggregations = $value['search_result']->getSearchAggregation();
+
+ if ($aggregations) {
+ /** @var StoreInterface $store */
+ $store = $context->getExtensionAttributes()->getStore();
+ $storeId = (int)$store->getId();
+ return $this->layerBuilder->build($aggregations, $storeId);
+ } else {
+ return [];
+ }
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Products.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Products.php
index e0580213ddea7..abc5ae7e1da7f 100644
--- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Products.php
+++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Category/Products.php
@@ -8,6 +8,9 @@
namespace Magento\CatalogGraphQl\Model\Resolver\Category;
use Magento\Catalog\Api\ProductRepositoryInterface;
+use Magento\CatalogGraphQl\DataProvider\Product\SearchCriteriaBuilder;
+use Magento\CatalogGraphQl\Model\Resolver\Products\Query\Search;
+use Magento\Framework\App\ObjectManager;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
@@ -27,27 +30,46 @@ class Products implements ResolverInterface
/**
* @var Builder
+ * @deprecated
*/
private $searchCriteriaBuilder;
/**
* @var Filter
+ * @deprecated
*/
private $filterQuery;
+ /**
+ * @var Search
+ */
+ private $searchQuery;
+
+ /**
+ * @var SearchCriteriaBuilder
+ */
+ private $searchApiCriteriaBuilder;
+
/**
* @param ProductRepositoryInterface $productRepository
* @param Builder $searchCriteriaBuilder
* @param Filter $filterQuery
+ * @param Search $searchQuery
+ * @param SearchCriteriaBuilder $searchApiCriteriaBuilder
*/
public function __construct(
ProductRepositoryInterface $productRepository,
Builder $searchCriteriaBuilder,
- Filter $filterQuery
+ Filter $filterQuery,
+ Search $searchQuery = null,
+ SearchCriteriaBuilder $searchApiCriteriaBuilder = null
) {
$this->productRepository = $productRepository;
$this->searchCriteriaBuilder = $searchCriteriaBuilder;
$this->filterQuery = $filterQuery;
+ $this->searchQuery = $searchQuery ?? ObjectManager::getInstance()->get(Search::class);
+ $this->searchApiCriteriaBuilder = $searchApiCriteriaBuilder ??
+ ObjectManager::getInstance()->get(SearchCriteriaBuilder::class);
}
/**
@@ -60,21 +82,20 @@ public function resolve(
array $value = null,
array $args = null
) {
- $args['filter'] = [
- 'category_id' => [
- 'eq' => $value['id']
- ]
- ];
- $searchCriteria = $this->searchCriteriaBuilder->build($field->getName(), $args);
if ($args['currentPage'] < 1) {
throw new GraphQlInputException(__('currentPage value must be greater than 0.'));
}
if ($args['pageSize'] < 1) {
throw new GraphQlInputException(__('pageSize value must be greater than 0.'));
}
- $searchCriteria->setCurrentPage($args['currentPage']);
- $searchCriteria->setPageSize($args['pageSize']);
- $searchResult = $this->filterQuery->getResult($searchCriteria, $info);
+
+ $args['filter'] = [
+ 'category_id' => [
+ 'eq' => $value['id']
+ ]
+ ];
+ $searchCriteria = $this->searchApiCriteriaBuilder->build($args, false);
+ $searchResult = $this->searchQuery->getResult($searchCriteria, $info);
//possible division by 0
if ($searchCriteria->getPageSize()) {
diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoryTree.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoryTree.php
index 89d3805383e1a..4284aed610848 100644
--- a/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoryTree.php
+++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/CategoryTree.php
@@ -10,11 +10,11 @@
use Magento\Catalog\Model\Category;
use Magento\CatalogGraphQl\Model\Resolver\Category\CheckCategoryIsActive;
use Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\ExtractDataFromCategoryTree;
-use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Magento\Framework\GraphQl\Config\Element\Field;
-use Magento\Framework\GraphQl\Exception\GraphQlInputException;
use Magento\Framework\GraphQl\Exception\GraphQlNoSuchEntityException;
use Magento\Framework\GraphQl\Query\ResolverInterface;
+use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
+use Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\CategoryTree as CategoryTreeDataProvider;
/**
* Category tree field resolver, used for GraphQL request processing.
@@ -27,7 +27,7 @@ class CategoryTree implements ResolverInterface
const CATEGORY_INTERFACE = 'CategoryInterface';
/**
- * @var \Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\CategoryTree
+ * @var CategoryTreeDataProvider
*/
private $categoryTree;
@@ -42,12 +42,12 @@ class CategoryTree implements ResolverInterface
private $checkCategoryIsActive;
/**
- * @param \Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\CategoryTree $categoryTree
+ * @param CategoryTreeDataProvider $categoryTree
* @param ExtractDataFromCategoryTree $extractDataFromCategoryTree
* @param CheckCategoryIsActive $checkCategoryIsActive
*/
public function __construct(
- \Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\CategoryTree $categoryTree,
+ CategoryTreeDataProvider $categoryTree,
ExtractDataFromCategoryTree $extractDataFromCategoryTree,
CheckCategoryIsActive $checkCategoryIsActive
) {
@@ -56,22 +56,6 @@ public function __construct(
$this->checkCategoryIsActive = $checkCategoryIsActive;
}
- /**
- * Get category id
- *
- * @param array $args
- * @return int
- * @throws GraphQlInputException
- */
- private function getCategoryId(array $args) : int
- {
- if (!isset($args['id'])) {
- throw new GraphQlInputException(__('"id for category should be specified'));
- }
-
- return (int)$args['id'];
- }
-
/**
* @inheritdoc
*/
@@ -81,7 +65,9 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value
return $value[$field->getName()];
}
- $rootCategoryId = $this->getCategoryId($args);
+ $rootCategoryId = isset($args['id']) ? (int)$args['id'] :
+ (int)$context->getExtensionAttributes()->getStore()->getRootCategoryId();
+
if ($rootCategoryId !== Category::TREE_ROOT_ID) {
$this->checkCategoryIsActive->execute($rootCategoryId);
}
diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products.php
index a75a9d2cf50a0..691f93e4148bc 100644
--- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products.php
+++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products.php
@@ -16,6 +16,7 @@
use Magento\Framework\GraphQl\Query\Resolver\Argument\SearchCriteria\SearchFilter;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Catalog\Model\Layer\Resolver;
+use Magento\CatalogGraphQl\DataProvider\Product\SearchCriteriaBuilder;
/**
* Products field resolver, used for GraphQL request processing.
@@ -24,6 +25,7 @@ class Products implements ResolverInterface
{
/**
* @var Builder
+ * @deprecated
*/
private $searchCriteriaBuilder;
@@ -34,30 +36,41 @@ class Products implements ResolverInterface
/**
* @var Filter
+ * @deprecated
*/
private $filterQuery;
/**
* @var SearchFilter
+ * @deprecated
*/
private $searchFilter;
+ /**
+ * @var SearchCriteriaBuilder
+ */
+ private $searchApiCriteriaBuilder;
+
/**
* @param Builder $searchCriteriaBuilder
* @param Search $searchQuery
* @param Filter $filterQuery
* @param SearchFilter $searchFilter
+ * @param SearchCriteriaBuilder|null $searchApiCriteriaBuilder
*/
public function __construct(
Builder $searchCriteriaBuilder,
Search $searchQuery,
Filter $filterQuery,
- SearchFilter $searchFilter
+ SearchFilter $searchFilter,
+ SearchCriteriaBuilder $searchApiCriteriaBuilder = null
) {
$this->searchCriteriaBuilder = $searchCriteriaBuilder;
$this->searchQuery = $searchQuery;
$this->filterQuery = $filterQuery;
$this->searchFilter = $searchFilter;
+ $this->searchApiCriteriaBuilder = $searchApiCriteriaBuilder ??
+ \Magento\Framework\App\ObjectManager::getInstance()->get(SearchCriteriaBuilder::class);
}
/**
@@ -70,40 +83,29 @@ public function resolve(
array $value = null,
array $args = null
) {
- $searchCriteria = $this->searchCriteriaBuilder->build($field->getName(), $args);
if ($args['currentPage'] < 1) {
throw new GraphQlInputException(__('currentPage value must be greater than 0.'));
}
if ($args['pageSize'] < 1) {
throw new GraphQlInputException(__('pageSize value must be greater than 0.'));
}
- $searchCriteria->setCurrentPage($args['currentPage']);
- $searchCriteria->setPageSize($args['pageSize']);
if (!isset($args['search']) && !isset($args['filter'])) {
throw new GraphQlInputException(
__("'search' or 'filter' input argument is required.")
);
- } elseif (isset($args['search'])) {
- $layerType = Resolver::CATALOG_LAYER_SEARCH;
- $this->searchFilter->add($args['search'], $searchCriteria);
- $searchResult = $this->searchQuery->getResult($searchCriteria, $info);
- } else {
- $layerType = Resolver::CATALOG_LAYER_CATEGORY;
- $searchResult = $this->filterQuery->getResult($searchCriteria, $info);
- }
- //possible division by 0
- if ($searchCriteria->getPageSize()) {
- $maxPages = ceil($searchResult->getTotalCount() / $searchCriteria->getPageSize());
- } else {
- $maxPages = 0;
}
- $currentPage = $searchCriteria->getCurrentPage();
- if ($searchCriteria->getCurrentPage() > $maxPages && $searchResult->getTotalCount() > 0) {
+ //get product children fields queried
+ $productFields = (array)$info->getFieldSelection(1);
+ $includeAggregations = isset($productFields['filters']) || isset($productFields['aggregations']);
+ $searchCriteria = $this->searchApiCriteriaBuilder->build($args, $includeAggregations);
+ $searchResult = $this->searchQuery->getResult($searchCriteria, $info, $args);
+
+ if ($searchResult->getCurrentPage() > $searchResult->getTotalPages() && $searchResult->getTotalCount() > 0) {
throw new GraphQlInputException(
__(
'currentPage value %1 specified is greater than the %2 page(s) available.',
- [$currentPage, $maxPages]
+ [$searchResult->getCurrentPage(), $searchResult->getTotalPages()]
)
);
}
@@ -112,11 +114,12 @@ public function resolve(
'total_count' => $searchResult->getTotalCount(),
'items' => $searchResult->getProductsSearchResult(),
'page_info' => [
- 'page_size' => $searchCriteria->getPageSize(),
- 'current_page' => $currentPage,
- 'total_pages' => $maxPages
+ 'page_size' => $searchResult->getPageSize(),
+ 'current_page' => $searchResult->getCurrentPage(),
+ 'total_pages' => $searchResult->getTotalPages()
],
- 'layer_type' => $layerType
+ 'search_result' => $searchResult,
+ 'layer_type' => isset($args['search']) ? Resolver::CATALOG_LAYER_SEARCH : Resolver::CATALOG_LAYER_CATEGORY,
];
return $data;
diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product.php
index e5e0d1aea4285..2076ec6726988 100644
--- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product.php
+++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product.php
@@ -8,6 +8,7 @@
namespace Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider;
use Magento\Catalog\Model\Product\Visibility;
+use Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\CollectionPostProcessor;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory;
use Magento\Catalog\Api\Data\ProductSearchResultsInterfaceFactory;
@@ -32,7 +33,12 @@ class Product
/**
* @var CollectionProcessorInterface
*/
- private $collectionProcessor;
+ private $collectionPreProcessor;
+
+ /**
+ * @var CollectionPostProcessor
+ */
+ private $collectionPostProcessor;
/**
* @var Visibility
@@ -44,17 +50,20 @@ class Product
* @param ProductSearchResultsInterfaceFactory $searchResultsFactory
* @param Visibility $visibility
* @param CollectionProcessorInterface $collectionProcessor
+ * @param CollectionPostProcessor $collectionPostProcessor
*/
public function __construct(
CollectionFactory $collectionFactory,
ProductSearchResultsInterfaceFactory $searchResultsFactory,
Visibility $visibility,
- CollectionProcessorInterface $collectionProcessor
+ CollectionProcessorInterface $collectionProcessor,
+ CollectionPostProcessor $collectionPostProcessor
) {
$this->collectionFactory = $collectionFactory;
$this->searchResultsFactory = $searchResultsFactory;
$this->visibility = $visibility;
- $this->collectionProcessor = $collectionProcessor;
+ $this->collectionPreProcessor = $collectionProcessor;
+ $this->collectionPostProcessor = $collectionPostProcessor;
}
/**
@@ -75,7 +84,7 @@ public function getList(
/** @var \Magento\Catalog\Model\ResourceModel\Product\Collection $collection */
$collection = $this->collectionFactory->create();
- $this->collectionProcessor->process($collection, $searchCriteria, $attributes);
+ $this->collectionPreProcessor->process($collection, $searchCriteria, $attributes);
if (!$isChildSearch) {
$visibilityIds = $isSearch
@@ -83,18 +92,9 @@ public function getList(
: $this->visibility->getVisibleInCatalogIds();
$collection->setVisibility($visibilityIds);
}
- $collection->load();
- // Methods that perform extra fetches post-load
- if (in_array('media_gallery_entries', $attributes)) {
- $collection->addMediaGalleryData();
- }
- if (in_array('media_gallery', $attributes)) {
- $collection->addMediaGalleryData();
- }
- if (in_array('options', $attributes)) {
- $collection->addOptionsToResult();
- }
+ $collection->load();
+ $this->collectionPostProcessor->process($collection, $attributes);
$searchResult = $this->searchResultsFactory->create();
$searchResult->setSearchCriteria($searchCriteria);
diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionPostProcessor.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionPostProcessor.php
new file mode 100644
index 0000000000000..fadf22e7643af
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/Product/CollectionPostProcessor.php
@@ -0,0 +1,42 @@
+isLoaded()) {
+ $collection->load();
+ }
+ // Methods that perform extra fetches post-load
+ if (in_array('media_gallery_entries', $attributeNames)) {
+ $collection->addMediaGalleryData();
+ }
+ if (in_array('media_gallery', $attributeNames)) {
+ $collection->addMediaGalleryData();
+ }
+ if (in_array('options', $attributeNames)) {
+ $collection->addOptionsToResult();
+ }
+
+ return $collection;
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/ProductSearch.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/ProductSearch.php
new file mode 100644
index 0000000000000..ff845f4796763
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/DataProvider/ProductSearch.php
@@ -0,0 +1,144 @@
+collectionFactory = $collectionFactory;
+ $this->searchResultsFactory = $searchResultsFactory;
+ $this->collectionPreProcessor = $collectionPreProcessor;
+ $this->collectionPostProcessor = $collectionPostProcessor;
+ $this->searchResultApplierFactory = $searchResultsApplierFactory;
+ }
+
+ /**
+ * Get list of product data with full data set. Adds eav attributes to result set from passed in array
+ *
+ * @param SearchCriteriaInterface $searchCriteria
+ * @param SearchResultInterface $searchResult
+ * @param array $attributes
+ * @return SearchResultsInterface
+ */
+ public function getList(
+ SearchCriteriaInterface $searchCriteria,
+ SearchResultInterface $searchResult,
+ array $attributes = []
+ ): SearchResultsInterface {
+ /** @var Collection $collection */
+ $collection = $this->collectionFactory->create();
+
+ //Join search results
+ $this->getSearchResultsApplier($searchResult, $collection, $this->getSortOrderArray($searchCriteria))->apply();
+
+ $this->collectionPreProcessor->process($collection, $searchCriteria, $attributes);
+ $collection->load();
+ $this->collectionPostProcessor->process($collection, $attributes);
+
+ $searchResults = $this->searchResultsFactory->create();
+ $searchResults->setSearchCriteria($searchCriteria);
+ $searchResults->setItems($collection->getItems());
+ $searchResults->setTotalCount($searchResult->getTotalCount());
+ return $searchResults;
+ }
+
+ /**
+ * Create searchResultApplier
+ *
+ * @param SearchResultInterface $searchResult
+ * @param Collection $collection
+ * @param array $orders
+ * @return SearchResultApplierInterface
+ */
+ private function getSearchResultsApplier(
+ SearchResultInterface $searchResult,
+ Collection $collection,
+ array $orders
+ ): SearchResultApplierInterface {
+ return $this->searchResultApplierFactory->create(
+ [
+ 'collection' => $collection,
+ 'searchResult' => $searchResult,
+ 'orders' => $orders
+ ]
+ );
+ }
+
+ /**
+ * Format sort orders into associative array
+ *
+ * E.g. ['field1' => 'DESC', 'field2' => 'ASC", ...]
+ *
+ * @param SearchCriteriaInterface $searchCriteria
+ * @return array
+ */
+ private function getSortOrderArray(SearchCriteriaInterface $searchCriteria)
+ {
+ $ordersArray = [];
+ $sortOrders = $searchCriteria->getSortOrders();
+ if (is_array($sortOrders)) {
+ foreach ($sortOrders as $sortOrder) {
+ $ordersArray[$sortOrder->getField()] = $sortOrder->getDirection();
+ }
+ }
+
+ return $ordersArray;
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/FilterArgument/ProductEntityAttributesForAst.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/FilterArgument/ProductEntityAttributesForAst.php
index a547f63b217fe..973b8fbcd6b0f 100644
--- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/FilterArgument/ProductEntityAttributesForAst.php
+++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/FilterArgument/ProductEntityAttributesForAst.php
@@ -23,13 +23,15 @@ class ProductEntityAttributesForAst implements FieldEntityAttributesInterface
private $config;
/**
+ * Additional attributes that are not retrieved by getting fields from ProductInterface
+ *
* @var array
*/
private $additionalAttributes = ['min_price', 'max_price', 'category_id'];
/**
* @param ConfigInterface $config
- * @param array $additionalAttributes
+ * @param string[] $additionalAttributes
*/
public function __construct(
ConfigInterface $config,
@@ -40,7 +42,12 @@ public function __construct(
}
/**
- * {@inheritdoc}
+ * @inheritdoc
+ *
+ * Gather all the product entity attributes that can be filtered by search criteria.
+ * Example format ['attributeNameInGraphQl' => ['type' => 'String'. 'fieldName' => 'attributeNameInSearchCriteria']]
+ *
+ * @return array
*/
public function getEntityAttributes() : array
{
@@ -55,14 +62,20 @@ public function getEntityAttributes() : array
$configElement = $this->config->getConfigElement($interface['interface']);
foreach ($configElement->getFields() as $field) {
- $fields[$field->getName()] = 'String';
+ $fields[$field->getName()] = [
+ 'type' => 'String',
+ 'fieldName' => $field->getName(),
+ ];
}
}
- foreach ($this->additionalAttributes as $attribute) {
- $fields[$attribute] = 'String';
+ foreach ($this->additionalAttributes as $attributeName) {
+ $fields[$attributeName] = [
+ 'type' => 'String',
+ 'fieldName' => $attributeName,
+ ];
}
- return array_keys($fields);
+ return $fields;
}
}
diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/FieldSelection.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/FieldSelection.php
new file mode 100644
index 0000000000000..3912bab05ebbe
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/FieldSelection.php
@@ -0,0 +1,93 @@
+fieldTranslator = $fieldTranslator;
+ }
+
+ /**
+ * Get requested fields from products query
+ *
+ * @param ResolveInfo $resolveInfo
+ * @return string[]
+ */
+ public function getProductsFieldSelection(ResolveInfo $resolveInfo): array
+ {
+ return $this->getProductFields($resolveInfo);
+ }
+
+ /**
+ * Return field names for all requested product fields.
+ *
+ * @param ResolveInfo $info
+ * @return string[]
+ */
+ private function getProductFields(ResolveInfo $info): array
+ {
+ $fieldNames = [];
+ foreach ($info->fieldNodes as $node) {
+ if ($node->name->value !== 'products') {
+ continue;
+ }
+ foreach ($node->selectionSet->selections as $selection) {
+ if ($selection->name->value !== 'items') {
+ continue;
+ }
+ $fieldNames[] = $this->collectProductFieldNames($selection, $fieldNames);
+ }
+ }
+
+ $fieldNames = array_merge(...$fieldNames);
+
+ return $fieldNames;
+ }
+
+ /**
+ * Collect field names for each node in selection
+ *
+ * @param SelectionNode $selection
+ * @param array $fieldNames
+ * @return array
+ */
+ private function collectProductFieldNames(SelectionNode $selection, array $fieldNames = []): array
+ {
+ foreach ($selection->selectionSet->selections as $itemSelection) {
+ if ($itemSelection->kind === 'InlineFragment') {
+ foreach ($itemSelection->selectionSet->selections as $inlineSelection) {
+ if ($inlineSelection->kind === 'InlineFragment') {
+ continue;
+ }
+ $fieldNames[] = $this->fieldTranslator->translate($inlineSelection->name->value);
+ }
+ continue;
+ }
+ $fieldNames[] = $this->fieldTranslator->translate($itemSelection->name->value);
+ }
+
+ return $fieldNames;
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Filter.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Filter.php
index 62e2f0c488c6c..cc25af44fdfbe 100644
--- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Filter.php
+++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Filter.php
@@ -12,7 +12,6 @@
use Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product;
use Magento\CatalogGraphQl\Model\Resolver\Products\SearchResult;
use Magento\CatalogGraphQl\Model\Resolver\Products\SearchResultFactory;
-use Magento\Framework\GraphQl\Query\FieldTranslator;
/**
* Retrieve filtered product data based off given search criteria in a format that GraphQL can interpret.
@@ -30,31 +29,31 @@ class Filter
private $productDataProvider;
/**
- * @var FieldTranslator
+ * @var \Magento\Catalog\Model\Layer\Resolver
*/
- private $fieldTranslator;
+ private $layerResolver;
/**
- * @var \Magento\Catalog\Model\Layer\Resolver
+ * FieldSelection
*/
- private $layerResolver;
+ private $fieldSelection;
/**
* @param SearchResultFactory $searchResultFactory
* @param Product $productDataProvider
* @param \Magento\Catalog\Model\Layer\Resolver $layerResolver
- * @param FieldTranslator $fieldTranslator
+ * @param FieldSelection $fieldSelection
*/
public function __construct(
SearchResultFactory $searchResultFactory,
Product $productDataProvider,
\Magento\Catalog\Model\Layer\Resolver $layerResolver,
- FieldTranslator $fieldTranslator
+ FieldSelection $fieldSelection
) {
$this->searchResultFactory = $searchResultFactory;
$this->productDataProvider = $productDataProvider;
- $this->fieldTranslator = $fieldTranslator;
$this->layerResolver = $layerResolver;
+ $this->fieldSelection = $fieldSelection;
}
/**
@@ -70,7 +69,7 @@ public function getResult(
ResolveInfo $info,
bool $isSearch = false
): SearchResult {
- $fields = $this->getProductFields($info);
+ $fields = $this->fieldSelection->getProductsFieldSelection($info);
$products = $this->productDataProvider->getList($searchCriteria, $fields, $isSearch);
$productArray = [];
/** @var \Magento\Catalog\Model\Product $product */
@@ -79,42 +78,11 @@ public function getResult(
$productArray[$product->getId()]['model'] = $product;
}
- return $this->searchResultFactory->create($products->getTotalCount(), $productArray);
- }
-
- /**
- * Return field names for all requested product fields.
- *
- * @param ResolveInfo $info
- * @return string[]
- */
- private function getProductFields(ResolveInfo $info) : array
- {
- $fieldNames = [];
- foreach ($info->fieldNodes as $node) {
- if ($node->name->value !== 'products') {
- continue;
- }
- foreach ($node->selectionSet->selections as $selection) {
- if ($selection->name->value !== 'items') {
- continue;
- }
-
- foreach ($selection->selectionSet->selections as $itemSelection) {
- if ($itemSelection->kind === 'InlineFragment') {
- foreach ($itemSelection->selectionSet->selections as $inlineSelection) {
- if ($inlineSelection->kind === 'InlineFragment') {
- continue;
- }
- $fieldNames[] = $this->fieldTranslator->translate($inlineSelection->name->value);
- }
- continue;
- }
- $fieldNames[] = $this->fieldTranslator->translate($itemSelection->name->value);
- }
- }
- }
-
- return $fieldNames;
+ return $this->searchResultFactory->create(
+ [
+ 'totalCount' => $products->getTotalCount(),
+ 'productsSearchResult' => $productArray
+ ]
+ );
}
}
diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Search.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Search.php
index bc40c664425ff..ef83cc6132ecc 100644
--- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Search.php
+++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/Query/Search.php
@@ -7,12 +7,13 @@
namespace Magento\CatalogGraphQl\Model\Resolver\Products\Query;
+use Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\ProductSearch;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Magento\Framework\Api\Search\SearchCriteriaInterface;
-use Magento\CatalogGraphQl\Model\Resolver\Products\SearchCriteria\Helper\Filter as FilterHelper;
use Magento\CatalogGraphQl\Model\Resolver\Products\SearchResult;
use Magento\CatalogGraphQl\Model\Resolver\Products\SearchResultFactory;
use Magento\Search\Api\SearchInterface;
+use Magento\Framework\Api\Search\SearchCriteriaInterfaceFactory;
/**
* Full text search for catalog using given search criteria.
@@ -25,52 +26,52 @@ class Search
private $search;
/**
- * @var FilterHelper
+ * @var SearchResultFactory
*/
- private $filterHelper;
+ private $searchResultFactory;
/**
- * @var Filter
+ * @var \Magento\Search\Model\Search\PageSizeProvider
*/
- private $filterQuery;
+ private $pageSizeProvider;
/**
- * @var SearchResultFactory
+ * @var SearchCriteriaInterfaceFactory
*/
- private $searchResultFactory;
+ private $searchCriteriaFactory;
/**
- * @var \Magento\Framework\EntityManager\MetadataPool
+ * @var FieldSelection
*/
- private $metadataPool;
+ private $fieldSelection;
/**
- * @var \Magento\Search\Model\Search\PageSizeProvider
+ * @var ProductSearch
*/
- private $pageSizeProvider;
+ private $productsProvider;
/**
* @param SearchInterface $search
- * @param FilterHelper $filterHelper
- * @param Filter $filterQuery
* @param SearchResultFactory $searchResultFactory
- * @param \Magento\Framework\EntityManager\MetadataPool $metadataPool
* @param \Magento\Search\Model\Search\PageSizeProvider $pageSize
+ * @param SearchCriteriaInterfaceFactory $searchCriteriaFactory
+ * @param FieldSelection $fieldSelection
+ * @param ProductSearch $productsProvider
*/
public function __construct(
SearchInterface $search,
- FilterHelper $filterHelper,
- Filter $filterQuery,
SearchResultFactory $searchResultFactory,
- \Magento\Framework\EntityManager\MetadataPool $metadataPool,
- \Magento\Search\Model\Search\PageSizeProvider $pageSize
+ \Magento\Search\Model\Search\PageSizeProvider $pageSize,
+ SearchCriteriaInterfaceFactory $searchCriteriaFactory,
+ FieldSelection $fieldSelection,
+ ProductSearch $productsProvider
) {
$this->search = $search;
- $this->filterHelper = $filterHelper;
- $this->filterQuery = $filterQuery;
$this->searchResultFactory = $searchResultFactory;
- $this->metadataPool = $metadataPool;
$this->pageSizeProvider = $pageSize;
+ $this->searchCriteriaFactory = $searchCriteriaFactory;
+ $this->fieldSelection = $fieldSelection;
+ $this->productsProvider = $productsProvider;
}
/**
@@ -81,11 +82,12 @@ public function __construct(
* @return SearchResult
* @throws \Exception
*/
- public function getResult(SearchCriteriaInterface $searchCriteria, ResolveInfo $info) : SearchResult
- {
- $idField = $this->metadataPool->getMetadata(
- \Magento\Catalog\Api\Data\ProductInterface::class
- )->getIdentifierField();
+ public function getResult(
+ SearchCriteriaInterface $searchCriteria,
+ ResolveInfo $info
+ ): SearchResult {
+ $queryFields = $this->fieldSelection->getProductsFieldSelection($info);
+
$realPageSize = $searchCriteria->getPageSize();
$realCurrentPage = $searchCriteria->getCurrentPage();
// Current page must be set to 0 and page size to max for search to grab all ID's as temporary workaround
@@ -94,64 +96,39 @@ public function getResult(SearchCriteriaInterface $searchCriteria, ResolveInfo $
$searchCriteria->setCurrentPage(0);
$itemsResults = $this->search->search($searchCriteria);
- $ids = [];
- $searchIds = [];
- foreach ($itemsResults->getItems() as $item) {
- $ids[$item->getId()] = null;
- $searchIds[] = $item->getId();
- }
-
- $filter = $this->filterHelper->generate($idField, 'in', $searchIds);
- $searchCriteria = $this->filterHelper->remove($searchCriteria, 'search_term');
- $searchCriteria = $this->filterHelper->add($searchCriteria, $filter);
- $searchResult = $this->filterQuery->getResult($searchCriteria, $info, true);
-
- $searchCriteria->setPageSize($realPageSize);
- $searchCriteria->setCurrentPage($realCurrentPage);
- $paginatedProducts = $this->paginateList($searchResult, $searchCriteria);
-
- $products = [];
- if (!isset($searchCriteria->getSortOrders()[0])) {
- foreach ($paginatedProducts as $product) {
- if (in_array($product[$idField], $searchIds)) {
- $ids[$product[$idField]] = $product;
- }
- }
- $products = array_filter($ids);
- } else {
- foreach ($paginatedProducts as $product) {
- $productId = isset($product['entity_id']) ? $product['entity_id'] : $product[$idField];
- if (in_array($productId, $searchIds)) {
- $products[] = $product;
- }
- }
- }
+ //Create copy of search criteria without conditions (conditions will be applied by joining search result)
+ $searchCriteriaCopy = $this->searchCriteriaFactory->create()
+ ->setSortOrders($searchCriteria->getSortOrders())
+ ->setPageSize($realPageSize)
+ ->setCurrentPage($realCurrentPage);
- return $this->searchResultFactory->create($searchResult->getTotalCount(), $products);
- }
+ $searchResults = $this->productsProvider->getList($searchCriteriaCopy, $itemsResults, $queryFields);
- /**
- * Paginate an array of Ids that get pulled back in search based off search criteria and total count.
- *
- * @param SearchResult $searchResult
- * @param SearchCriteriaInterface $searchCriteria
- * @return int[]
- */
- private function paginateList(SearchResult $searchResult, SearchCriteriaInterface $searchCriteria) : array
- {
- $length = $searchCriteria->getPageSize();
- // Search starts pages from 0
- $offset = $length * ($searchCriteria->getCurrentPage() - 1);
-
- if ($searchCriteria->getPageSize()) {
- $maxPages = ceil($searchResult->getTotalCount() / $searchCriteria->getPageSize());
+ //possible division by 0
+ if ($realPageSize) {
+ $maxPages = (int)ceil($searchResults->getTotalCount() / $realPageSize);
} else {
$maxPages = 0;
}
+ $searchCriteria->setPageSize($realPageSize);
+ $searchCriteria->setCurrentPage($realCurrentPage);
- if ($searchCriteria->getCurrentPage() > $maxPages && $searchResult->getTotalCount() > 0) {
- $offset = (int)$maxPages;
+ $productArray = [];
+ /** @var \Magento\Catalog\Model\Product $product */
+ foreach ($searchResults->getItems() as $product) {
+ $productArray[$product->getId()] = $product->getData();
+ $productArray[$product->getId()]['model'] = $product;
}
- return array_slice($searchResult->getProductsSearchResult(), $offset, $length);
+
+ return $this->searchResultFactory->create(
+ [
+ 'totalCount' => $searchResults->getTotalCount(),
+ 'productsSearchResult' => $productArray,
+ 'searchAggregation' => $itemsResults->getAggregations(),
+ 'pageSize' => $realPageSize,
+ 'currentPage' => $realCurrentPage,
+ 'totalPages' => $maxPages,
+ ]
+ );
}
}
diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResult.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResult.php
index 6e229bdc38a31..e4a137413b4c5 100644
--- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResult.php
+++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResult.php
@@ -7,31 +7,21 @@
namespace Magento\CatalogGraphQl\Model\Resolver\Products;
-use Magento\Framework\Api\SearchResultsInterface;
+use Magento\Framework\Api\Search\AggregationInterface;
/**
* Container for a product search holding the item result and the array in the GraphQL-readable product type format.
*/
class SearchResult
{
- /**
- * @var SearchResultsInterface
- */
- private $totalCount;
-
- /**
- * @var array
- */
- private $productsSearchResult;
+ private $data;
/**
- * @param int $totalCount
- * @param array $productsSearchResult
+ * @param array $data
*/
- public function __construct(int $totalCount, array $productsSearchResult)
+ public function __construct(array $data)
{
- $this->totalCount = $totalCount;
- $this->productsSearchResult = $productsSearchResult;
+ $this->data = $data;
}
/**
@@ -41,7 +31,7 @@ public function __construct(int $totalCount, array $productsSearchResult)
*/
public function getTotalCount() : int
{
- return $this->totalCount;
+ return $this->data['totalCount'] ?? 0;
}
/**
@@ -51,6 +41,46 @@ public function getTotalCount() : int
*/
public function getProductsSearchResult() : array
{
- return $this->productsSearchResult;
+ return $this->data['productsSearchResult'] ?? [];
+ }
+
+ /**
+ * Retrieve aggregated search results
+ *
+ * @return AggregationInterface|null
+ */
+ public function getSearchAggregation(): ?AggregationInterface
+ {
+ return $this->data['searchAggregation'] ?? null;
+ }
+
+ /**
+ * Retrieve the page size for the search
+ *
+ * @return int
+ */
+ public function getPageSize(): int
+ {
+ return $this->data['pageSize'] ?? 0;
+ }
+
+ /**
+ * Retrieve the current page for the search
+ *
+ * @return int
+ */
+ public function getCurrentPage(): int
+ {
+ return $this->data['currentPage'] ?? 0;
+ }
+
+ /**
+ * Retrieve total pages for the search
+ *
+ * @return int
+ */
+ public function getTotalPages(): int
+ {
+ return $this->data['totalPages'] ?? 0;
}
}
diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResultFactory.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResultFactory.php
index aec9362f47c3a..479e6a3f96235 100644
--- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResultFactory.php
+++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Products/SearchResultFactory.php
@@ -30,15 +30,15 @@ public function __construct(ObjectManagerInterface $objectManager)
/**
* Instantiate SearchResult
*
- * @param int $totalCount
- * @param array $productsSearchResult
+ * @param array $data
* @return SearchResult
*/
- public function create(int $totalCount, array $productsSearchResult) : SearchResult
- {
+ public function create(
+ array $data
+ ): SearchResult {
return $this->objectManager->create(
SearchResult::class,
- ['totalCount' => $totalCount, 'productsSearchResult' => $productsSearchResult]
+ ['data' => $data]
);
}
}
diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/RootCategoryId.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/RootCategoryId.php
new file mode 100644
index 0000000000000..4b3e0a1a58dfd
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/RootCategoryId.php
@@ -0,0 +1,26 @@
+getExtensionAttributes()->getStore()->getRootCategoryId();
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/Plugin/Search/Request/ConfigReader.php b/app/code/Magento/CatalogGraphQl/Plugin/Search/Request/ConfigReader.php
new file mode 100644
index 0000000000000..992ab50467c72
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/Plugin/Search/Request/ConfigReader.php
@@ -0,0 +1,286 @@
+generatorResolver = $generatorResolver;
+ $this->productAttributeCollectionFactory = $productAttributeCollectionFactory;
+ $this->exactMatchAttributes = array_merge($this->exactMatchAttributes, $exactMatchAttributes);
+ }
+
+ /**
+ * Merge reader's value with generated
+ *
+ * @param \Magento\Framework\Config\ReaderInterface $subject
+ * @param array $result
+ * @return array
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ public function afterRead(
+ \Magento\Framework\Config\ReaderInterface $subject,
+ array $result
+ ) {
+ $searchRequestNameWithAggregation = $this->generateRequest();
+ $searchRequest = $searchRequestNameWithAggregation;
+ $searchRequest['queries'][$this->requestName] = $searchRequest['queries'][$this->requestNameWithAggregation];
+ unset($searchRequest['queries'][$this->requestNameWithAggregation], $searchRequest['aggregations']);
+
+ return array_merge_recursive(
+ $result,
+ [
+ $this->requestNameWithAggregation => $searchRequestNameWithAggregation,
+ $this->requestName => $searchRequest,
+ ]
+ );
+ }
+
+ /**
+ * Retrieve searchable attributes
+ *
+ * @return Attribute[]
+ */
+ private function getSearchableAttributes(): array
+ {
+ $attributes = [];
+ /** @var Collection $productAttributes */
+ $productAttributes = $this->productAttributeCollectionFactory->create();
+ $productAttributes->addFieldToFilter(
+ ['is_searchable', 'is_visible_in_advanced_search', 'is_filterable', 'is_filterable_in_search'],
+ [1, 1, [1, 2], 1]
+ );
+
+ /** @var Attribute $attribute */
+ foreach ($productAttributes->getItems() as $attribute) {
+ $attributes[$attribute->getAttributeCode()] = $attribute;
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Generate search request for search products via GraphQL
+ *
+ * @return array
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
+ */
+ private function generateRequest()
+ {
+ $request = [];
+ foreach ($this->getSearchableAttributes() as $attribute) {
+ if (\in_array($attribute->getAttributeCode(), ['price', 'visibility', 'category_ids'])) {
+ //some fields have special semantics
+ continue;
+ }
+ $queryName = $attribute->getAttributeCode() . '_query';
+ $filterName = $attribute->getAttributeCode() . RequestGenerator::FILTER_SUFFIX;
+ $request['queries'][$this->requestNameWithAggregation]['queryReference'][] = [
+ 'clause' => 'must',
+ 'ref' => $queryName,
+ ];
+
+ switch ($attribute->getBackendType()) {
+ case 'static':
+ case 'text':
+ case 'varchar':
+ if ($this->isExactMatchAttribute($attribute)) {
+ $request['queries'][$queryName] = $this->generateFilterQuery($queryName, $filterName);
+ $request['filters'][$filterName] = $this->generateTermFilter($filterName, $attribute);
+ } else {
+ $request['queries'][$queryName] = $this->generateMatchQuery($queryName, $attribute);
+ }
+ break;
+ case 'decimal':
+ case 'datetime':
+ case 'date':
+ $request['queries'][$queryName] = $this->generateFilterQuery($queryName, $filterName);
+ $request['filters'][$filterName] = $this->generateRangeFilter($filterName, $attribute);
+ break;
+ default:
+ $request['queries'][$queryName] = $this->generateFilterQuery($queryName, $filterName);
+ $request['filters'][$filterName] = $this->generateTermFilter($filterName, $attribute);
+ }
+ $generator = $this->generatorResolver->getGeneratorForType($attribute->getBackendType());
+
+ if ($attribute->getData(EavAttributeInterface::IS_FILTERABLE)) {
+ $bucketName = $attribute->getAttributeCode() . self::BUCKET_SUFFIX;
+ $request['aggregations'][$bucketName] = $generator->getAggregationData($attribute, $bucketName);
+ }
+
+ $this->addSearchAttributeToFullTextSearch($attribute, $request);
+ }
+
+ return $request;
+ }
+
+ /**
+ * Add attribute with specified boost to "search" query used in full text search
+ *
+ * @param Attribute $attribute
+ * @param array $request
+ * @return void
+ */
+ private function addSearchAttributeToFullTextSearch(Attribute $attribute, &$request): void
+ {
+ // Match search by custom price attribute isn't supported
+ if ($attribute->getFrontendInput() !== 'price') {
+ $request['queries']['search']['match'][] = [
+ 'field' => $attribute->getAttributeCode(),
+ 'boost' => $attribute->getSearchWeight() ?: 1,
+ ];
+ }
+ }
+
+ /**
+ * Return array representation of range filter
+ *
+ * @param string $filterName
+ * @param Attribute $attribute
+ * @return array
+ */
+ private function generateRangeFilter(string $filterName, Attribute $attribute)
+ {
+ return [
+ 'field' => $attribute->getAttributeCode(),
+ 'name' => $filterName,
+ 'type' => FilterInterface::TYPE_RANGE,
+ 'from' => '$' . $attribute->getAttributeCode() . '.from$',
+ 'to' => '$' . $attribute->getAttributeCode() . '.to$',
+ ];
+ }
+
+ /**
+ * Return array representation of term filter
+ *
+ * @param string $filterName
+ * @param Attribute $attribute
+ * @return array
+ */
+ private function generateTermFilter(string $filterName, Attribute $attribute)
+ {
+ return [
+ 'type' => FilterInterface::TYPE_TERM,
+ 'name' => $filterName,
+ 'field' => $attribute->getAttributeCode(),
+ 'value' => '$' . $attribute->getAttributeCode() . '$',
+ ];
+ }
+
+ /**
+ * Return array representation of query based on filter
+ *
+ * @param string $queryName
+ * @param string $filterName
+ * @return array
+ */
+ private function generateFilterQuery(string $queryName, string $filterName)
+ {
+ return [
+ 'name' => $queryName,
+ 'type' => QueryInterface::TYPE_FILTER,
+ 'filterReference' => [
+ [
+ 'ref' => $filterName,
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Return array representation of match query
+ *
+ * @param string $queryName
+ * @param Attribute $attribute
+ * @return array
+ */
+ private function generateMatchQuery(string $queryName, Attribute $attribute)
+ {
+ return [
+ 'name' => $queryName,
+ 'type' => 'matchQuery',
+ 'value' => '$' . $attribute->getAttributeCode() . '$',
+ 'match' => [
+ [
+ 'field' => $attribute->getAttributeCode(),
+ 'boost' => $attribute->getSearchWeight() ?: 1,
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Check if attribute's filter should use exact match
+ *
+ * @param Attribute $attribute
+ * @return bool
+ */
+ private function isExactMatchAttribute(Attribute $attribute)
+ {
+ if (in_array($attribute->getFrontendInput(), ['select', 'multiselect'])) {
+ return true;
+ }
+ if (in_array($attribute->getAttributeCode(), $this->exactMatchAttributes)) {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/app/code/Magento/CatalogGraphQl/composer.json b/app/code/Magento/CatalogGraphQl/composer.json
index 13fcbe9a7d357..1582f29c25951 100644
--- a/app/code/Magento/CatalogGraphQl/composer.json
+++ b/app/code/Magento/CatalogGraphQl/composer.json
@@ -10,6 +10,7 @@
"magento/module-search": "*",
"magento/module-store": "*",
"magento/module-eav-graph-ql": "*",
+ "magento/module-catalog-search": "*",
"magento/framework": "*"
},
"suggest": {
diff --git a/app/code/Magento/CatalogGraphQl/etc/di.xml b/app/code/Magento/CatalogGraphQl/etc/di.xml
index a5006355ed265..485ae792193e3 100644
--- a/app/code/Magento/CatalogGraphQl/etc/di.xml
+++ b/app/code/Magento/CatalogGraphQl/etc/di.xml
@@ -19,6 +19,8 @@
- Magento\CatalogGraphQl\Model\Config\AttributeReader
- Magento\CatalogGraphQl\Model\Config\CategoryAttributeReader
+ - Magento\CatalogGraphQl\Model\Config\SortAttributeReader
+ - Magento\CatalogGraphQl\Model\Config\FilterAttributeReader
@@ -55,4 +57,16 @@
Magento\Catalog\Model\Api\SearchCriteria\ProductCollectionProcessor
+
+
+
+
+ - sku
+
+
+
+
+
+
+
diff --git a/app/code/Magento/CatalogGraphQl/etc/graphql/di.xml b/app/code/Magento/CatalogGraphQl/etc/graphql/di.xml
index 2292004f3cf01..fe3413dc3b218 100644
--- a/app/code/Magento/CatalogGraphQl/etc/graphql/di.xml
+++ b/app/code/Magento/CatalogGraphQl/etc/graphql/di.xml
@@ -28,6 +28,13 @@
+
+
+
+ - Magento\CatalogGraphQl\Model\AggregationOptionTypeResolver
+
+
+
@@ -48,6 +55,12 @@
- CustomizableRadioOption
- CustomizableCheckboxOption
+ -
+
- ProductAttributeSortInput
+
+ -
+
- ProductAttributeFilterInput
+
@@ -95,4 +108,14 @@
+
+
+
+
+ - Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Builder\Price
+ - Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Builder\Category
+ - Magento\CatalogGraphQl\DataProvider\Product\LayeredNavigation\Builder\Attribute
+
+
+
diff --git a/app/code/Magento/CatalogGraphQl/etc/schema.graphqls b/app/code/Magento/CatalogGraphQl/etc/schema.graphqls
index ea56faf94408e..76a58857cebc2 100644
--- a/app/code/Magento/CatalogGraphQl/etc/schema.graphqls
+++ b/app/code/Magento/CatalogGraphQl/etc/schema.graphqls
@@ -4,10 +4,10 @@
type Query {
products (
search: String @doc(description: "Performs a full-text search using the specified key words."),
- filter: ProductFilterInput @doc(description: "Identifies which product attributes to search for and return."),
+ filter: ProductAttributeFilterInput @doc(description: "Identifies which product attributes to search for and return."),
pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once. This attribute is optional."),
currentPage: Int = 1 @doc(description: "Specifies which page of results to return. The default value is 1."),
- sort: ProductSortInput @doc(description: "Specifies which attribute to sort on, and whether to return the results in ascending or descending order.")
+ sort: ProductAttributeSortInput @doc(description: "Specifies which attributes to sort on, and whether to return the results in ascending or descending order.")
): Products
@resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Products") @doc(description: "The products query searches for products that match the criteria specified in the search and filter attributes.") @cache(cacheIdentity: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\Identity")
category (
@@ -221,7 +221,7 @@ interface CategoryInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model
products(
pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once. This attribute is optional."),
currentPage: Int = 1 @doc(description: "Specifies which page of results to return. The default value is 1."),
- sort: ProductSortInput @doc(description: "Specifies which attribute to sort on, and whether to return the results in ascending or descending order.")
+ sort: ProductAttributeSortInput @doc(description: "Specifies which attributes to sort on, and whether to return the results in ascending or descending order.")
): CategoryProducts @doc(description: "The list of products assigned to the category.") @cache(cacheIdentity: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\Identity") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\Products")
breadcrumbs: [Breadcrumb] @doc(description: "Breadcrumbs, parent categories info.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\Breadcrumbs")
}
@@ -270,7 +270,8 @@ type Products @doc(description: "The Products object is the top-level object ret
items: [ProductInterface] @doc(description: "An array of products that match the specified search criteria.")
page_info: SearchResultPageInfo @doc(description: "An object that includes the page_info and currentPage values specified in the query.")
total_count: Int @doc(description: "The number of products returned.")
- filters: [LayerFilter] @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\LayerFilters") @doc(description: "Layered navigation filters array.")
+ filters: [LayerFilter] @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\LayerFilters") @doc(description: "Layered navigation filters array.") @deprecated(reason: "Use aggregations instead")
+ aggregations: [Aggregation] @doc(description: "Layered navigation aggregations.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Aggregations")
sort_fields: SortFields @doc(description: "An object that includes the default sort field and all available sort fields.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\SortFields")
}
@@ -280,7 +281,11 @@ type CategoryProducts @doc(description: "The category products object returned i
total_count: Int @doc(description: "The number of products returned.")
}
-input ProductFilterInput @doc(description: "ProductFilterInput defines the filters to be used in the search. A filter contains at least one attribute, a comparison operator, and the value that is being searched for.") {
+input ProductAttributeFilterInput @doc(description: "ProductAttributeFilterInput defines the filters to be used in the search. A filter contains at least one attribute, a comparison operator, and the value that is being searched for.") {
+ category_id: FilterEqualTypeInput @doc(description: "Filter product by category id")
+}
+
+input ProductFilterInput @doc(description: "ProductFilterInput is deprecated, use @ProductAttributeFilterInput instead. ProductFilterInput defines the filters to be used in the search. A filter contains at least one attribute, a comparison operator, and the value that is being searched for.") {
name: FilterTypeInput @doc(description: "The product name. Customers use this name to identify the product.")
sku: FilterTypeInput @doc(description: "A number or code assigned to a product to identify the product, options, price, and manufacturer.")
description: FilterTypeInput @doc(description: "Detailed information about the product. The value can include simple HTML tags.")
@@ -333,7 +338,7 @@ type ProductMediaGalleryEntriesVideoContent @doc(description: "ProductMediaGalle
video_metadata: String @doc(description: "Optional data about the video.")
}
-input ProductSortInput @doc(description: "ProductSortInput specifies the attribute to use for sorting search results and indicates whether the results are sorted in ascending or descending order.") {
+input ProductSortInput @doc(description: "ProductSortInput is deprecated, use @ProductAttributeSortInput instead. ProductSortInput specifies the attribute to use for sorting search results and indicates whether the results are sorted in ascending or descending order.") {
name: SortEnum @doc(description: "The product name. Customers use this name to identify the product.")
sku: SortEnum @doc(description: "A number or code assigned to a product to identify the product, options, price, and manufacturer.")
description: SortEnum @doc(description: "Detailed information about the product. The value can include simple HTML tags.")
@@ -367,6 +372,12 @@ input ProductSortInput @doc(description: "ProductSortInput specifies the attribu
gift_message_available: SortEnum @doc(description: "Indicates whether a gift message is available.")
}
+input ProductAttributeSortInput @doc(description: "ProductAttributeSortInput specifies the attribute to use for sorting search results and indicates whether the results are sorted in ascending or descending order. It's possible to sort products using searchable attributes with enabled 'Use in Filter Options' option")
+{
+ relevance: SortEnum @doc(description: "Sort by the search relevance score (default).")
+ position: SortEnum @doc(description: "Sort by the position assigned to each product.")
+}
+
type MediaGalleryEntry @doc(description: "MediaGalleryEntry defines characteristics about images and videos associated with a specific product.") {
id: Int @doc(description: "The identifier assigned to the object.")
media_type: String @doc(description: "image or video.")
@@ -380,22 +391,39 @@ type MediaGalleryEntry @doc(description: "MediaGalleryEntry defines characterist
}
type LayerFilter {
- name: String @doc(description: "Layered navigation filter name.")
- request_var: String @doc(description: "Request variable name for filter query.")
- filter_items_count: Int @doc(description: "Count of filter items in filter group.")
- filter_items: [LayerFilterItemInterface] @doc(description: "Array of filter items.")
+ name: String @doc(description: "Layered navigation filter name.") @deprecated(reason: "Use Aggregation.label instead.")
+ request_var: String @doc(description: "Request variable name for filter query.") @deprecated(reason: "Use Aggregation.attribute_code instead.")
+ filter_items_count: Int @doc(description: "Count of filter items in filter group.") @deprecated(reason: "Use Aggregation.count instead.")
+ filter_items: [LayerFilterItemInterface] @doc(description: "Array of filter items.") @deprecated(reason: "Use Aggregation.options instead.")
}
interface LayerFilterItemInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\LayerFilterItemTypeResolverComposite") {
- label: String @doc(description: "Filter label.")
- value_string: String @doc(description: "Value for filter request variable to be used in query.")
- items_count: Int @doc(description: "Count of items by filter.")
+ label: String @doc(description: "Filter label.") @deprecated(reason: "Use AggregationOption.label instead.")
+ value_string: String @doc(description: "Value for filter request variable to be used in query.") @deprecated(reason: "Use AggregationOption.value instead.")
+ items_count: Int @doc(description: "Count of items by filter.") @deprecated(reason: "Use AggregationOption.count instead.")
}
type LayerFilterItem implements LayerFilterItemInterface {
}
+type Aggregation @doc(description: "A bucket that contains information for each filterable option (such as price, category ID, and custom attributes).") {
+ count: Int @doc(description: "The number of options in the aggregation group.")
+ label: String @doc(description: "The aggregation display name.")
+ attribute_code: String! @doc(description: "Attribute code of the aggregation group.")
+ options: [AggregationOption] @doc(description: "Array of options for the aggregation.")
+}
+
+interface AggregationOptionInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\AggregationOptionTypeResolverComposite") {
+ count: Int @doc(description: "The number of items that match the aggregation option.")
+ label: String @doc(description: "Aggregation option display label.")
+ value: String! @doc(description: "The internal ID that represents the value of the option.")
+}
+
+type AggregationOption implements AggregationOptionInterface {
+
+}
+
type SortField {
value: String @doc(description: "Attribute code of sort field.")
label: String @doc(description: "Label of sort field.")
@@ -416,6 +444,7 @@ type StoreConfig @doc(description: "The type contains information about a store
grid_per_page : Int @doc(description: "Products per Page on Grid Default Value.")
list_per_page : Int @doc(description: "Products per Page on List Default Value.")
catalog_default_sort_by : String @doc(description: "Default Sort By.")
+ root_category_id: Int @doc(description: "The ID of the root category") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\RootCategoryId")
}
type ProductVideo @doc(description: "Contains information about a product video.") implements MediaGalleryInterface {
diff --git a/app/code/Magento/CatalogGraphQl/etc/search_request.xml b/app/code/Magento/CatalogGraphQl/etc/search_request.xml
new file mode 100644
index 0000000000000..ab1eea9eb6fda
--- /dev/null
+++ b/app/code/Magento/CatalogGraphQl/etc/search_request.xml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ 10000
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ 10000
+
+
diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product.php b/app/code/Magento/CatalogImportExport/Model/Import/Product.php
index 5c083d421f0e1..1ae993ed99060 100644
--- a/app/code/Magento/CatalogImportExport/Model/Import/Product.php
+++ b/app/code/Magento/CatalogImportExport/Model/Import/Product.php
@@ -141,7 +141,7 @@ class Product extends \Magento\ImportExport\Model\Import\Entity\AbstractEntity
const COL_PRODUCT_WEBSITES = '_product_websites';
/**
- * Media gallery attribute code.
+ * Attribute code for media gallery.
*/
const MEDIA_GALLERY_ATTRIBUTE_CODE = 'media_gallery';
@@ -151,12 +151,12 @@ class Product extends \Magento\ImportExport\Model\Import\Entity\AbstractEntity
const COL_MEDIA_IMAGE = '_media_image';
/**
- * Inventory use config.
+ * Inventory use config label.
*/
const INVENTORY_USE_CONFIG = 'Use Config';
/**
- * Inventory use config prefix.
+ * Prefix for inventory use config.
*/
const INVENTORY_USE_CONFIG_PREFIX = 'use_config_';
@@ -1192,8 +1192,10 @@ protected function _initTypeModels()
if ($model->isSuitable()) {
$this->_productTypeModels[$productTypeName] = $model;
}
+ // phpcs:disable Magento2.Performance.ForeachArrayMerge.ForeachArrayMerge
$this->_fieldsMap = array_merge($this->_fieldsMap, $model->getCustomFieldsMapping());
$this->_specialAttributes = array_merge($this->_specialAttributes, $model->getParticularAttributes());
+ // phpcs:enable
}
$this->_initErrorTemplates();
// remove doubles
@@ -1884,6 +1886,7 @@ protected function _saveProducts()
return $this;
}
+ //phpcs:enable Generic.Metrics.NestingLevel
/**
* Prepare array with image states (visible or hidden from product page)
@@ -2734,8 +2737,6 @@ protected function _saveValidatedBunches()
try {
$rowData = $source->current();
} catch (\InvalidArgumentException $e) {
- $this->addRowError($e->getMessage(), $this->_processedRowsCount);
- $this->_processedRowsCount++;
$source->next();
continue;
}
@@ -2972,6 +2973,10 @@ private function formatStockDataForRow(array $rowData): array
$stockItemDo = $this->stockRegistry->getStockItem($row['product_id'], $row['website_id']);
$existStockData = $stockItemDo->getData();
+ if (isset($rowData['qty']) && $rowData['qty'] == 0 && !isset($rowData['is_in_stock'])) {
+ $rowData['is_in_stock'] = 0;
+ }
+
$row = array_merge(
$this->defaultStockData,
array_intersect_key($existStockData, $this->defaultStockData),
diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php b/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php
index 6cdafa7fc6f5a..4d8088a235402 100644
--- a/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php
+++ b/app/code/Magento/CatalogImportExport/Model/Import/Product/Option.php
@@ -684,7 +684,12 @@ protected function _getNewOptionsWithTheSameTitlesErrorRows(array $sourceProduct
ksort($outerTitles);
ksort($innerTitles);
if ($outerTitles === $innerTitles) {
- $errorRows = array_merge($errorRows, $innerData['rows'], $outerData['rows']);
+ foreach ($innerData['rows'] as $innerDataRow) {
+ $errorRows[] = $innerDataRow;
+ }
+ foreach ($outerData['rows'] as $outerDataRow) {
+ $errorRows[] = $outerDataRow;
+ }
}
}
}
@@ -719,7 +724,9 @@ protected function _findOldOptionsWithTheSameTitles()
}
}
if ($optionsCount > 1) {
- $errorRows = array_merge($errorRows, $outerData['rows']);
+ foreach ($outerData['rows'] as $dataRow) {
+ $errorRows[] = $dataRow;
+ }
}
}
}
@@ -747,7 +754,9 @@ protected function _findNewOldOptionsTypeMismatch()
ksort($outerTitles);
ksort($innerTitles);
if ($outerTitles === $innerTitles && $outerData['type'] != $innerData['type']) {
- $errorRows = array_merge($errorRows, $outerData['rows']);
+ foreach ($outerData['rows'] as $dataRow) {
+ $errorRows[] = $dataRow;
+ }
}
}
}
@@ -959,8 +968,10 @@ public function validateRow(array $rowData, $rowNumber)
$multiRowData = $this->_getMultiRowFormat($rowData);
- foreach ($multiRowData as $optionData) {
- $combinedData = array_merge($rowData, $optionData);
+ foreach ($multiRowData as $combinedData) {
+ foreach ($rowData as $key => $field) {
+ $combinedData[$key] = $field;
+ }
if ($this->_isRowWithCustomOption($combinedData)) {
if ($this->_isMainOptionRow($combinedData)) {
@@ -1109,15 +1120,15 @@ protected function _getMultiRowFormat($rowData)
foreach ($rowData['custom_options'] as $name => $customOption) {
$i++;
foreach ($customOption as $rowOrder => $optionRow) {
- $row = array_merge(
- [
- self::COLUMN_STORE => '',
- self::COLUMN_TITLE => $name,
- self::COLUMN_SORT_ORDER => $i,
- self::COLUMN_ROW_SORT => $rowOrder
- ],
- $this->processOptionRow($name, $optionRow)
- );
+ $row = [
+ self::COLUMN_STORE => '',
+ self::COLUMN_TITLE => $name,
+ self::COLUMN_SORT_ORDER => $i,
+ self::COLUMN_ROW_SORT => $rowOrder
+ ];
+ foreach ($this->processOptionRow($name, $optionRow) as $key => $value) {
+ $row[$key] = $value;
+ }
$name = '';
$multiRow[] = $row;
}
@@ -1215,6 +1226,8 @@ private function addFileOptions($result, $optionRow)
*
* @return boolean
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.NPathComplexity)
*/
protected function _importData()
{
@@ -1256,9 +1269,11 @@ protected function _importData()
$optionsToRemove[] = $this->_rowProductId;
}
}
- foreach ($multiRowData as $optionData) {
- $combinedData = array_merge($rowData, $optionData);
+ foreach ($multiRowData as $combinedData) {
+ foreach ($rowData as $key => $field) {
+ $combinedData[$key] = $field;
+ }
if (!$this->isRowAllowedToImport($combinedData, $rowNumber)
|| !$this->_parseRequiredData($combinedData)
) {
@@ -1441,7 +1456,11 @@ protected function _collectOptionMainData(
if (!$this->_isRowHasSpecificType($this->_rowType)
&& ($priceData = $this->_getPriceData($rowData, $nextOptionId, $this->_rowType))
) {
- $prices[$nextOptionId] = $priceData;
+ if ($this->_isPriceGlobal) {
+ $prices[$nextOptionId][Store::DEFAULT_STORE_ID] = $priceData;
+ } else {
+ $prices[$nextOptionId][$this->_rowStoreId] = $priceData;
+ }
}
if (!isset($products[$this->_rowProductId])) {
@@ -1547,6 +1566,7 @@ protected function _collectOptionTitle(array $rowData, $prevOptionId, array &$ti
* @param array &$prices
* @param array &$typeValues
* @return $this
+ * @SuppressWarnings(PHPMD.UnusedLocalVariable)
*/
protected function _compareOptionsWithExisting(array &$options, array &$titles, array &$prices, array &$typeValues)
{
@@ -1557,7 +1577,9 @@ protected function _compareOptionsWithExisting(array &$options, array &$titles,
$titles[$optionId] = $titles[$newOptionId];
unset($titles[$newOptionId]);
if (isset($prices[$newOptionId])) {
- $prices[$newOptionId]['option_id'] = $optionId;
+ foreach ($prices[$newOptionId] as $storeId => $priceStoreData) {
+ $prices[$newOptionId][$storeId]['option_id'] = $optionId;
+ }
}
if (isset($typeValues[$newOptionId])) {
$typeValues[$optionId] = $typeValues[$newOptionId];
@@ -1590,8 +1612,10 @@ private function restoreOriginalOptionTypeIds(array &$typeValues, array &$typePr
$optionType['option_type_id'] = $existingTypeId;
$typeTitles[$existingTypeId] = $typeTitles[$optionTypeId];
unset($typeTitles[$optionTypeId]);
- $typePrices[$existingTypeId] = $typePrices[$optionTypeId];
- unset($typePrices[$optionTypeId]);
+ if (isset($typePrices[$optionTypeId])) {
+ $typePrices[$existingTypeId] = $typePrices[$optionTypeId];
+ unset($typePrices[$optionTypeId]);
+ }
// If option type titles match at least in one store, consider current option type as existing
break;
}
@@ -1651,7 +1675,7 @@ protected function _parseRequiredData(array $rowData)
if (!isset($this->_storeCodeToId[$rowData[self::COLUMN_STORE]])) {
return false;
}
- $this->_rowStoreId = $this->_storeCodeToId[$rowData[self::COLUMN_STORE]];
+ $this->_rowStoreId = (int)$this->_storeCodeToId[$rowData[self::COLUMN_STORE]];
} else {
$this->_rowStoreId = Store::DEFAULT_STORE_ID;
}
@@ -1767,7 +1791,7 @@ protected function _getPriceData(array $rowData, $optionId, $type)
) {
$priceData = [
'option_id' => $optionId,
- 'store_id' => Store::DEFAULT_STORE_ID,
+ 'store_id' => $this->_rowStoreId,
'price_type' => 'fixed',
];
@@ -1901,11 +1925,19 @@ protected function _saveTitles(array $titles)
protected function _savePrices(array $prices)
{
if ($prices) {
- $this->_connection->insertOnDuplicate(
- $this->_tables['catalog_product_option_price'],
- $prices,
- ['price', 'price_type']
- );
+ $optionPriceRows = [];
+ foreach ($prices as $storesData) {
+ foreach ($storesData as $row) {
+ $optionPriceRows[] = $row;
+ }
+ }
+ if ($optionPriceRows) {
+ $this->_connection->insertOnDuplicate(
+ $this->_tables['catalog_product_option_price'],
+ $optionPriceRows,
+ ['price', 'price_type']
+ );
+ }
}
return $this;
diff --git a/app/code/Magento/CatalogImportExport/Test/Mftf/Test/AdminExportImportConfigurableProductWithImagesTest.xml b/app/code/Magento/CatalogImportExport/Test/Mftf/Test/AdminExportImportConfigurableProductWithImagesTest.xml
new file mode 100644
index 0000000000000..88f6e6c9f9039
--- /dev/null
+++ b/app/code/Magento/CatalogImportExport/Test/Mftf/Test/AdminExportImportConfigurableProductWithImagesTest.xml
@@ -0,0 +1,216 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/Type/OptionTest.php b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/Type/OptionTest.php
index f0a52a67e0095..9f63decac5ff7 100644
--- a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/Type/OptionTest.php
+++ b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/Product/Type/OptionTest.php
@@ -79,8 +79,8 @@ class OptionTest extends \Magento\ImportExport\Test\Unit\Model\Import\AbstractIm
* @var array
*/
protected $_expectedPrices = [
- 2 => ['option_id' => 2, 'store_id' => 0, 'price_type' => 'fixed', 'price' => 0],
- 3 => ['option_id' => 3, 'store_id' => 0, 'price_type' => 'fixed', 'price' => 2]
+ 0 => ['option_id' => 2, 'store_id' => 0, 'price_type' => 'fixed', 'price' => 0],
+ 1 => ['option_id' => 3, 'store_id' => 0, 'price_type' => 'fixed', 'price' => 2]
];
/**
diff --git a/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock/Item.php b/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock/Item.php
index edccad60231ec..3a214bd8cd7cb 100644
--- a/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock/Item.php
+++ b/app/code/Magento/CatalogInventory/Model/ResourceModel/Stock/Item.php
@@ -14,6 +14,7 @@
use Magento\Framework\DB\Select;
use Magento\Framework\App\ObjectManager;
use Magento\Framework\Stdlib\DateTime\DateTime;
+use Zend_Db_Expr;
/**
* Stock item resource model
@@ -183,16 +184,12 @@ public function updateSetOutOfStock(int $websiteId)
'is_in_stock = ' . Stock::STOCK_IN_STOCK,
'(use_config_manage_stock = 1 AND 1 = ' . $this->stockConfiguration->getManageStock() . ')'
. ' OR (use_config_manage_stock = 0 AND manage_stock = 1)',
- '(use_config_min_qty = 1 AND qty <= ' . $this->stockConfiguration->getMinQty() . ')'
- . ' OR (use_config_min_qty = 0 AND qty <= min_qty)',
+ '(' . $this->getBackordersExpr() .' = 0 AND qty <= ' . $this->getMinQtyExpr() . ')'
+ . ' OR (' . $this->getBackordersExpr() .' != 0 AND '
+ . $this->getMinQtyExpr() . ' != 0 AND qty <= ' . $this->getMinQtyExpr() . ')',
'product_id IN (' . $select->assemble() . ')',
];
- $backordersWhere = '(use_config_backorders = 0 AND backorders = ' . Stock::BACKORDERS_NO . ')';
- if (Stock::BACKORDERS_NO == $this->stockConfiguration->getBackorders()) {
- $where[] = $backordersWhere . ' OR use_config_backorders = 1';
- } else {
- $where[] = $backordersWhere;
- }
+
$connection->update($this->getMainTable(), $values, $where);
$this->stockIndexerProcessor->markIndexerAsInvalid();
@@ -215,8 +212,8 @@ public function updateSetInStock(int $websiteId)
$where = [
'website_id = ' . $websiteId,
'stock_status_changed_auto = 1',
- '(use_config_min_qty = 1 AND qty > ' . $this->stockConfiguration->getMinQty() . ')'
- . ' OR (use_config_min_qty = 0 AND qty > min_qty)',
+ '(qty > ' . $this->getMinQtyExpr() . ')'
+ . ' OR (' . $this->getBackordersExpr() . ' != 0 AND ' . $this->getMinQtyExpr() . ' = 0)', // If infinite
'product_id IN (' . $select->assemble() . ')',
];
$manageStockWhere = '(use_config_manage_stock = 0 AND manage_stock = 1)';
@@ -304,12 +301,12 @@ public function getBackordersExpr(string $tableAlias = ''): \Zend_Db_Expr
}
/**
- * Get Minimum Sale Quantity Expression
+ * Get Minimum Sale Quantity Expression.
*
* @param string $tableAlias
- * @return \Zend_Db_Expr
+ * @return Zend_Db_Expr
*/
- public function getMinSaleQtyExpr(string $tableAlias = ''): \Zend_Db_Expr
+ public function getMinSaleQtyExpr(string $tableAlias = ''): Zend_Db_Expr
{
if ($tableAlias) {
$tableAlias .= '.';
@@ -323,6 +320,26 @@ public function getMinSaleQtyExpr(string $tableAlias = ''): \Zend_Db_Expr
return $itemMinSaleQty;
}
+ /**
+ * Get Min Qty Expression
+ *
+ * @param string $tableAlias
+ * @return Zend_Db_Expr
+ */
+ public function getMinQtyExpr(string $tableAlias = ''): Zend_Db_Expr
+ {
+ if ($tableAlias) {
+ $tableAlias .= '.';
+ }
+ $itemBackorders = $this->getConnection()->getCheckSql(
+ $tableAlias . 'use_config_min_qty = 1',
+ $this->stockConfiguration->getMinQty(),
+ $tableAlias . 'min_qty'
+ );
+
+ return $itemBackorders;
+ }
+
/**
* Build select for products with types from config
*
diff --git a/app/code/Magento/CatalogInventory/Model/StockStateProvider.php b/app/code/Magento/CatalogInventory/Model/StockStateProvider.php
index 6851b05aa56a6..74271cdd97bf8 100644
--- a/app/code/Magento/CatalogInventory/Model/StockStateProvider.php
+++ b/app/code/Magento/CatalogInventory/Model/StockStateProvider.php
@@ -72,14 +72,31 @@ public function __construct(
*/
public function verifyStock(StockItemInterface $stockItem)
{
+ // Manage stock, but qty is null
if ($stockItem->getQty() === null && $stockItem->getManageStock()) {
return false;
}
+
+ // Backorders are not allowed and qty reached min qty
if ($stockItem->getBackorders() == StockItemInterface::BACKORDERS_NO
&& $stockItem->getQty() <= $stockItem->getMinQty()
) {
return false;
}
+
+ $backordersAllowed = [Stock::BACKORDERS_YES_NONOTIFY, Stock::BACKORDERS_YES_NOTIFY];
+ if (in_array($stockItem->getBackorders(), $backordersAllowed)) {
+ // Infinite - let it be In stock
+ if ($stockItem->getMinQty() == 0) {
+ return true;
+ }
+
+ // qty reached min qty - let it stand Out Of Stock
+ if ($stockItem->getQty() <= $stockItem->getMinQty()) {
+ return false;
+ }
+ }
+
return true;
}
@@ -245,15 +262,17 @@ public function checkQty(StockItemInterface $stockItem, $qty)
if (!$stockItem->getManageStock()) {
return true;
}
+
+ $backordersAllowed = [Stock::BACKORDERS_YES_NONOTIFY, Stock::BACKORDERS_YES_NOTIFY];
+ // Infinite check
+ if ($stockItem->getMinQty() == 0 && in_array($stockItem->getBackorders(), $backordersAllowed)) {
+ return true;
+ }
+
if ($stockItem->getQty() - $stockItem->getMinQty() - $qty < 0) {
- switch ($stockItem->getBackorders()) {
- case \Magento\CatalogInventory\Model\Stock::BACKORDERS_YES_NONOTIFY:
- case \Magento\CatalogInventory\Model\Stock::BACKORDERS_YES_NOTIFY:
- break;
- default:
- return false;
- }
+ return false;
}
+
return true;
}
diff --git a/app/code/Magento/CatalogInventory/Model/System/Config/Backend/Minqty.php b/app/code/Magento/CatalogInventory/Model/System/Config/Backend/Minqty.php
index f49d41b5dd656..268f1846161d4 100644
--- a/app/code/Magento/CatalogInventory/Model/System/Config/Backend/Minqty.php
+++ b/app/code/Magento/CatalogInventory/Model/System/Config/Backend/Minqty.php
@@ -6,21 +6,36 @@
namespace Magento\CatalogInventory\Model\System\Config\Backend;
+use Magento\CatalogInventory\Model\Stock;
+
/**
- * Minimum product qty backend model
+ * Minimum product qty backend model.
*/
class Minqty extends \Magento\Framework\App\Config\Value
{
/**
- * Validate minimum product qty value
+ * Validate minimum product qty value.
*
* @return $this
*/
public function beforeSave()
{
parent::beforeSave();
- $minQty = (int) $this->getValue() >= 0 ? (int) $this->getValue() : (int) $this->getOldValue();
+ $minQty = (float)$this->getValue();
+
+ /**
+ * As described in the documentation if the Backorders Option is disabled
+ * it is recommended to set the Out Of Stock Threshold to a positive number.
+ * That's why to clarify the logic to the end user the code below prevent him to set a negative number so such
+ * a number will turn to zero.
+ * @see https://docs.magento.com/m2/ce/user_guide/catalog/inventory-backorders.html
+ */
+ if ($this->getFieldsetDataValue("backorders") == Stock::BACKORDERS_NO && $minQty < 0) {
+ $minQty = 0;
+ }
+
$this->setValue((string) $minQty);
+
return $this;
}
}
diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/ActionGroup/AdminCatalogInventoryConfigurationActionGroup.xml b/app/code/Magento/CatalogInventory/Test/Mftf/ActionGroup/AdminCatalogInventoryConfigurationActionGroup.xml
new file mode 100644
index 0000000000000..49956473132ec
--- /dev/null
+++ b/app/code/Magento/CatalogInventory/Test/Mftf/ActionGroup/AdminCatalogInventoryConfigurationActionGroup.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/ActionGroup/AdminProductActionGroup.xml b/app/code/Magento/CatalogInventory/Test/Mftf/ActionGroup/AdminProductActionGroup.xml
new file mode 100644
index 0000000000000..84dc6b93c885f
--- /dev/null
+++ b/app/code/Magento/CatalogInventory/Test/Mftf/ActionGroup/AdminProductActionGroup.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventoryConfigData.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventoryConfigData.xml
index e14c36446fc2b..cd5a8cf5bbac9 100644
--- a/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventoryConfigData.xml
+++ b/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventoryConfigData.xml
@@ -20,4 +20,17 @@
No
0
+
+
+ cataloginventory/options/can_subtract
+ 0
+ Yes
+ 1
+
+
+ cataloginventory/options/can_subtract
+ 0
+ No
+ 0
+
diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventoryItemOptionsData.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventoryItemOptionsData.xml
new file mode 100644
index 0000000000000..767d65f9facca
--- /dev/null
+++ b/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventoryItemOptionsData.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+ MaxSaleQtyDefaultValue
+
+
+ 10000
+
+
diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventryConfigData.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventryConfigData.xml
deleted file mode 100644
index 3a49b821ead5f..0000000000000
--- a/app/code/Magento/CatalogInventory/Test/Mftf/Data/CatalogInventryConfigData.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
- cataloginventory/options/can_subtract
- 0
- Yes
- 1
-
-
- cataloginventory/options/can_subtract
- 0
- No
- 0
-
-
\ No newline at end of file
diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Metadata/cataloginventory_item_options-meta.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Metadata/cataloginventory_item_options-meta.xml
new file mode 100644
index 0000000000000..7672cb7478f1a
--- /dev/null
+++ b/app/code/Magento/CatalogInventory/Test/Mftf/Metadata/cataloginventory_item_options-meta.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Page/AdminInventoryProductStockOptionsConfigPage.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Page/AdminInventoryProductStockOptionsConfigPage.xml
new file mode 100644
index 0000000000000..3d8c3ef3cf9f8
--- /dev/null
+++ b/app/code/Magento/CatalogInventory/Test/Mftf/Page/AdminInventoryProductStockOptionsConfigPage.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Page/AdminProductCreatePage.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Page/AdminProductCreatePage.xml
new file mode 100644
index 0000000000000..5835e7564c172
--- /dev/null
+++ b/app/code/Magento/CatalogInventory/Test/Mftf/Page/AdminProductCreatePage.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminInventoryProductStockOptionsConfigSection.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminInventoryProductStockOptionsConfigSection.xml
new file mode 100644
index 0000000000000..ef7fe30f4970b
--- /dev/null
+++ b/app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminInventoryProductStockOptionsConfigSection.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormAdvancedInventorySection.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminProductFormAdvancedInventorySection.xml
similarity index 92%
rename from app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormAdvancedInventorySection.xml
rename to app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminProductFormAdvancedInventorySection.xml
index 4196a86fe25db..7ff9c2d70755f 100644
--- a/app/code/Magento/Catalog/Test/Mftf/Section/AdminProductFormAdvancedInventorySection.xml
+++ b/app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminProductFormAdvancedInventorySection.xml
@@ -30,5 +30,7 @@
+
+
diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminProductFormSection.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminProductFormSection.xml
new file mode 100644
index 0000000000000..f4b79b17b3fc3
--- /dev/null
+++ b/app/code/Magento/CatalogInventory/Test/Mftf/Section/AdminProductFormSection.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/app/code/Magento/CatalogInventory/Test/Mftf/Test/AdminCreateProductWithZeroMaximumQtyAllowedInShoppingCartTest.xml b/app/code/Magento/CatalogInventory/Test/Mftf/Test/AdminCreateProductWithZeroMaximumQtyAllowedInShoppingCartTest.xml
new file mode 100644
index 0000000000000..f7cf0a4deba4b
--- /dev/null
+++ b/app/code/Magento/CatalogInventory/Test/Mftf/Test/AdminCreateProductWithZeroMaximumQtyAllowedInShoppingCartTest.xml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/CatalogInventory/Test/Unit/Model/StockStateProviderTest.php b/app/code/Magento/CatalogInventory/Test/Unit/Model/StockStateProviderTest.php
new file mode 100644
index 0000000000000..942d77063a8e3
--- /dev/null
+++ b/app/code/Magento/CatalogInventory/Test/Unit/Model/StockStateProviderTest.php
@@ -0,0 +1,223 @@
+objectManager = new ObjectManager($this);
+
+ $this->stockItem = $this->getMockBuilder(StockItemInterface::class)
+ ->getMock();
+
+ $this->model = $this->objectManager->getObject(StockStateProvider::class);
+ }
+
+ /**
+ * Tests verifyStock method.
+ *
+ * @param int $qty
+ * @param int $backOrders
+ * @param int $minQty
+ * @param int $manageStock
+ * @param int $expected
+ *
+ * @return void
+ *
+ * @dataProvider stockItemDataProvider
+ * @covers \Magento\CatalogInventory\Model\StockStateProvider::verifyStock
+ */
+ public function testVerifyStock(
+ ?int $qty,
+ ?int $backOrders,
+ ?int $minQty,
+ ?int $manageStock,
+ bool $expected
+ ): void {
+ $this->stockItem->method('getQty')
+ ->willReturn($qty);
+ $this->stockItem->method('getBackOrders')
+ ->willReturn($backOrders);
+ $this->stockItem->method('getMinQty')
+ ->willReturn($minQty);
+ $this->stockItem->method('getManageStock')
+ ->willReturn($manageStock);
+
+ $result = $this->model->verifyStock($this->stockItem);
+
+ self::assertEquals($expected, $result);
+ }
+
+ /**
+ * StockItem data provider.
+ *
+ * @return array
+ */
+ public function stockItemDataProvider(): array
+ {
+ return [
+ 'qty_is_null_manage_stock_on' => [
+ 'qty' => null,
+ 'backorders' => null,
+ 'min_qty' => null,
+ 'manage_stock' => 1,
+ 'expected' => false,
+ ],
+ 'qty_reached_threshold_without_backorders' => [
+ 'qty' => 3,
+ 'backorders' => Stock::BACKORDERS_NO,
+ 'min_qty' => 3,
+ 'manage_stock' => 1,
+ 'expected' => false,
+ ],
+ 'backorders_are_ininite' => [
+ 'qty' => -100,
+ 'backorders' => Stock::BACKORDERS_YES_NONOTIFY,
+ 'min_qty' => 0,
+ 'manage_stock' => 1,
+ 'expected' => true,
+ ],
+ 'limited_backorders_and_qty_reached_threshold' => [
+ 'qty' => -100,
+ 'backorders' => Stock::BACKORDERS_YES_NONOTIFY,
+ 'min_qty' => -100,
+ 'manage_stock' => 1,
+ 'expected' => false,
+ ],
+ 'qty_not_yet_reached_threshold_1' => [
+ 'qty' => -99,
+ 'backorders' => Stock::BACKORDERS_YES_NONOTIFY,
+ 'min_qty' => -100,
+ 'manage_stock' => 1,
+ 'expected' => true,
+ ],
+ 'qty_not_yet_reached_threshold_2' => [
+ 'qty' => 1,
+ 'backorders' => Stock::BACKORDERS_NO,
+ 'min_qty' => 0,
+ 'manage_stock' => 1,
+ 'expected' => true,
+ ],
+ ];
+ }
+
+ /**
+ * Tests checkQty method.
+ *
+ * @return void
+ *
+ * @dataProvider stockItemAndQtyDataProvider
+ * @covers \Magento\CatalogInventory\Model\StockStateProvider::verifyStock
+ */
+ public function testCheckQty(
+ bool $manageStock,
+ int $qty,
+ int $minQty,
+ int $backOrders,
+ int $orderQty,
+ bool $expected
+ ): void {
+ $this->stockItem->method('getManageStock')
+ ->willReturn($manageStock);
+ $this->stockItem->method('getQty')
+ ->willReturn($qty);
+ $this->stockItem->method('getMinQty')
+ ->willReturn($minQty);
+ $this->stockItem->method('getBackOrders')
+ ->willReturn($backOrders);
+
+ $result = $this->model->checkQty($this->stockItem, $orderQty);
+
+ self::assertEquals($expected, $result);
+ }
+
+ /**
+ * StockItem and qty data provider.
+ *
+ * @return array
+ */
+ public function stockItemAndQtyDataProvider(): array
+ {
+ return [
+ 'disabled_manage_stock' => [
+ 'manage_stock' => false,
+ 'qty' => 0,
+ 'min_qty' => 0,
+ 'backorders' => 0,
+ 'order_qty' => 0,
+ 'expected' => true,
+ ],
+ 'infinite_backorders' => [
+ 'manage_stock' => true,
+ 'qty' => -100,
+ 'min_qty' => 0,
+ 'backorders' => Stock::BACKORDERS_YES_NONOTIFY,
+ 'order_qty' => 100,
+ 'expected' => true,
+ ],
+ 'qty_reached_threshold' => [
+ 'manage_stock' => true,
+ 'qty' => -100,
+ 'min_qty' => -100,
+ 'backorders' => Stock::BACKORDERS_YES_NOTIFY,
+ 'order_qty' => 1,
+ 'expected' => false,
+ ],
+ 'qty_yet_not_reached_threshold' => [
+ 'manage_stock' => true,
+ 'qty' => -100,
+ 'min_qty' => -100,
+ 'backorders' => Stock::BACKORDERS_YES_NOTIFY,
+ 'order_qty' => 1,
+ 'expected' => false,
+ ]
+ ];
+ }
+
+ /**
+ * Tests checkQty method when check is not applicable.
+ *
+ * @return void
+ */
+ public function testCheckQtyWhenCheckIsNotApplicable(): void
+ {
+ $model = $this->objectManager->getObject(StockStateProvider::class, ['qtyCheckApplicable' => false]);
+
+ $result = $model->checkQty($this->stockItem, 3);
+
+ self::assertTrue($result);
+ }
+}
diff --git a/app/code/Magento/CatalogInventory/etc/adminhtml/system.xml b/app/code/Magento/CatalogInventory/etc/adminhtml/system.xml
index 08ed0a8f49470..546f838b9b428 100644
--- a/app/code/Magento/CatalogInventory/etc/adminhtml/system.xml
+++ b/app/code/Magento/CatalogInventory/etc/adminhtml/system.xml
@@ -55,7 +55,7 @@
- validate-number
+ validate-number validate-greater-than-zero
diff --git a/app/code/Magento/CatalogInventory/view/adminhtml/ui_component/product_form.xml b/app/code/Magento/CatalogInventory/view/adminhtml/ui_component/product_form.xml
index fc0690157fb37..b813aa5d356cb 100644
--- a/app/code/Magento/CatalogInventory/view/adminhtml/ui_component/product_form.xml
+++ b/app/code/Magento/CatalogInventory/view/adminhtml/ui_component/product_form.xml
@@ -304,6 +304,7 @@
[GLOBAL]
+ true
true
diff --git a/app/code/Magento/CatalogRule/Controller/Adminhtml/Promo/Catalog/Save.php b/app/code/Magento/CatalogRule/Controller/Adminhtml/Promo/Catalog/Save.php
index 4f58293d53359..6d499b93e411f 100644
--- a/app/code/Magento/CatalogRule/Controller/Adminhtml/Promo/Catalog/Save.php
+++ b/app/code/Magento/CatalogRule/Controller/Adminhtml/Promo/Catalog/Save.php
@@ -12,6 +12,7 @@
use Magento\Framework\Registry;
use Magento\Framework\Stdlib\DateTime\Filter\Date;
use Magento\Framework\App\Request\DataPersistorInterface;
+use Magento\Framework\Stdlib\DateTime\TimezoneInterface;
/**
* Save action for catalog rule
@@ -25,19 +26,27 @@ class Save extends \Magento\CatalogRule\Controller\Adminhtml\Promo\Catalog imple
*/
protected $dataPersistor;
+ /**
+ * @var TimezoneInterface
+ */
+ private $localeDate;
+
/**
* @param Context $context
* @param Registry $coreRegistry
* @param Date $dateFilter
* @param DataPersistorInterface $dataPersistor
+ * @param TimezoneInterface $localeDate
*/
public function __construct(
Context $context,
Registry $coreRegistry,
Date $dateFilter,
- DataPersistorInterface $dataPersistor
+ DataPersistorInterface $dataPersistor,
+ TimezoneInterface $localeDate
) {
$this->dataPersistor = $dataPersistor;
+ $this->localeDate = $localeDate;
parent::__construct($context, $coreRegistry, $dateFilter);
}
@@ -46,16 +55,15 @@ public function __construct(
*
* @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface|void
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.NPathComplexity)
*/
public function execute()
{
if ($this->getRequest()->getPostValue()) {
-
/** @var \Magento\CatalogRule\Api\CatalogRuleRepositoryInterface $ruleRepository */
$ruleRepository = $this->_objectManager->get(
\Magento\CatalogRule\Api\CatalogRuleRepositoryInterface::class
);
-
/** @var \Magento\CatalogRule\Model\Rule $model */
$model = $this->_objectManager->create(\Magento\CatalogRule\Model\Rule::class);
@@ -65,7 +73,9 @@ public function execute()
['request' => $this->getRequest()]
);
$data = $this->getRequest()->getPostValue();
-
+ if (!$this->getRequest()->getParam('from_date')) {
+ $data['from_date'] = $this->localeDate->formatDate();
+ }
$filterValues = ['from_date' => $this->_dateFilter];
if ($this->getRequest()->getParam('to_date')) {
$filterValues['to_date'] = $this->_dateFilter;
diff --git a/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php b/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php
index 55a234bb8ae27..944710773123f 100644
--- a/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php
+++ b/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProduct.php
@@ -8,7 +8,10 @@
use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface as TableSwapper;
use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher;
-use Magento\Framework\App\ObjectManager;
+use Magento\CatalogRule\Model\Rule;
+use Magento\Framework\App\ResourceConnection;
+use Magento\Framework\Stdlib\DateTime\TimezoneInterface;
+use Magento\Store\Model\ScopeInterface;
/**
* Reindex rule relations with products.
@@ -16,7 +19,7 @@
class ReindexRuleProduct
{
/**
- * @var \Magento\Framework\App\ResourceConnection
+ * @var ResourceConnection
*/
private $resource;
@@ -31,36 +34,40 @@ class ReindexRuleProduct
private $tableSwapper;
/**
- * @param \Magento\Framework\App\ResourceConnection $resource
+ * @var TimezoneInterface
+ */
+ private $localeDate;
+
+ /**
+ * @param ResourceConnection $resource
* @param ActiveTableSwitcher $activeTableSwitcher
- * @param TableSwapper|null $tableSwapper
+ * @param TableSwapper $tableSwapper
+ * @param TimezoneInterface $localeDate
*/
public function __construct(
- \Magento\Framework\App\ResourceConnection $resource,
+ ResourceConnection $resource,
ActiveTableSwitcher $activeTableSwitcher,
- TableSwapper $tableSwapper = null
+ TableSwapper $tableSwapper,
+ TimezoneInterface $localeDate
) {
$this->resource = $resource;
$this->activeTableSwitcher = $activeTableSwitcher;
- $this->tableSwapper = $tableSwapper ??
- ObjectManager::getInstance()->get(TableSwapper::class);
+ $this->tableSwapper = $tableSwapper;
+ $this->localeDate = $localeDate;
}
/**
* Reindex information about rule relations with products.
*
- * @param \Magento\CatalogRule\Model\Rule $rule
+ * @param Rule $rule
* @param int $batchCount
* @param bool $useAdditionalTable
* @return bool
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
* @SuppressWarnings(PHPMD.NPathComplexity)
*/
- public function execute(
- \Magento\CatalogRule\Model\Rule $rule,
- $batchCount,
- $useAdditionalTable = false
- ) {
+ public function execute(Rule $rule, $batchCount, $useAdditionalTable = false)
+ {
if (!$rule->getIsActive() || empty($rule->getWebsiteIds())) {
return false;
}
@@ -84,21 +91,28 @@ public function execute(
$ruleId = $rule->getId();
$customerGroupIds = $rule->getCustomerGroupIds();
- $fromTime = strtotime($rule->getFromDate());
- $toTime = strtotime($rule->getToDate());
- $toTime = $toTime ? $toTime + \Magento\CatalogRule\Model\Indexer\IndexBuilder::SECONDS_IN_DAY - 1 : 0;
$sortOrder = (int)$rule->getSortOrder();
$actionOperator = $rule->getSimpleAction();
$actionAmount = $rule->getDiscountAmount();
$actionStop = $rule->getStopRulesProcessing();
$rows = [];
+ foreach ($websiteIds as $websiteId) {
+ $scopeTz = new \DateTimeZone(
+ $this->localeDate->getConfigTimezone(ScopeInterface::SCOPE_WEBSITE, $websiteId)
+ );
+ $fromTime = $rule->getFromDate()
+ ? (new \DateTime($rule->getFromDate(), $scopeTz))->getTimestamp()
+ : 0;
+ $toTime = $rule->getToDate()
+ ? (new \DateTime($rule->getToDate(), $scopeTz))->getTimestamp() + IndexBuilder::SECONDS_IN_DAY - 1
+ : 0;
- foreach ($productIds as $productId => $validationByWebsite) {
- foreach ($websiteIds as $websiteId) {
+ foreach ($productIds as $productId => $validationByWebsite) {
if (empty($validationByWebsite[$websiteId])) {
continue;
}
+
foreach ($customerGroupIds as $customerGroupId) {
$rows[] = [
'rule_id' => $ruleId,
@@ -123,6 +137,7 @@ public function execute(
if (!empty($rows)) {
$connection->insertMultiple($indexTable, $rows);
}
+
return true;
}
}
diff --git a/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProductPrice.php b/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProductPrice.php
index 6a87be3c50a64..11ba87730bec1 100644
--- a/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProductPrice.php
+++ b/app/code/Magento/CatalogRule/Model/Indexer/ReindexRuleProductPrice.php
@@ -6,54 +6,58 @@
namespace Magento\CatalogRule\Model\Indexer;
+use Magento\Catalog\Model\Product;
+use Magento\Framework\Stdlib\DateTime\TimezoneInterface;
+use Magento\Store\Model\StoreManagerInterface;
+
/**
* Reindex product prices according rule settings.
*/
class ReindexRuleProductPrice
{
/**
- * @var \Magento\Store\Model\StoreManagerInterface
+ * @var StoreManagerInterface
*/
private $storeManager;
/**
- * @var \Magento\CatalogRule\Model\Indexer\RuleProductsSelectBuilder
+ * @var RuleProductsSelectBuilder
*/
private $ruleProductsSelectBuilder;
/**
- * @var \Magento\CatalogRule\Model\Indexer\ProductPriceCalculator
+ * @var ProductPriceCalculator
*/
private $productPriceCalculator;
/**
- * @var \Magento\Framework\Stdlib\DateTime\DateTime
+ * @var TimezoneInterface
*/
- private $dateTime;
+ private $localeDate;
/**
- * @var \Magento\CatalogRule\Model\Indexer\RuleProductPricesPersistor
+ * @var RuleProductPricesPersistor
*/
private $pricesPersistor;
/**
- * @param \Magento\Store\Model\StoreManagerInterface $storeManager
+ * @param StoreManagerInterface $storeManager
* @param RuleProductsSelectBuilder $ruleProductsSelectBuilder
* @param ProductPriceCalculator $productPriceCalculator
- * @param \Magento\Framework\Stdlib\DateTime\DateTime $dateTime
- * @param \Magento\CatalogRule\Model\Indexer\RuleProductPricesPersistor $pricesPersistor
+ * @param TimezoneInterface $localeDate
+ * @param RuleProductPricesPersistor $pricesPersistor
*/
public function __construct(
- \Magento\Store\Model\StoreManagerInterface $storeManager,
- \Magento\CatalogRule\Model\Indexer\RuleProductsSelectBuilder $ruleProductsSelectBuilder,
- \Magento\CatalogRule\Model\Indexer\ProductPriceCalculator $productPriceCalculator,
- \Magento\Framework\Stdlib\DateTime\DateTime $dateTime,
- \Magento\CatalogRule\Model\Indexer\RuleProductPricesPersistor $pricesPersistor
+ StoreManagerInterface $storeManager,
+ RuleProductsSelectBuilder $ruleProductsSelectBuilder,
+ ProductPriceCalculator $productPriceCalculator,
+ TimezoneInterface $localeDate,
+ RuleProductPricesPersistor $pricesPersistor
) {
$this->storeManager = $storeManager;
$this->ruleProductsSelectBuilder = $ruleProductsSelectBuilder;
$this->productPriceCalculator = $productPriceCalculator;
- $this->dateTime = $dateTime;
+ $this->localeDate = $localeDate;
$this->pricesPersistor = $pricesPersistor;
}
@@ -61,22 +65,16 @@ public function __construct(
* Reindex product prices.
*
* @param int $batchCount
- * @param \Magento\Catalog\Model\Product|null $product
+ * @param Product|null $product
* @param bool $useAdditionalTable
* @return bool
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
*/
- public function execute(
- $batchCount,
- \Magento\Catalog\Model\Product $product = null,
- $useAdditionalTable = false
- ) {
- $fromDate = mktime(0, 0, 0, date('m'), date('d') - 1);
- $toDate = mktime(0, 0, 0, date('m'), date('d') + 1);
-
+ public function execute($batchCount, Product $product = null, $useAdditionalTable = false)
+ {
/**
* Update products rules prices per each website separately
- * because of max join limit in mysql
+ * because for each website date in website's timezone should be used
*/
foreach ($this->storeManager->getWebsites() as $website) {
$productsStmt = $this->ruleProductsSelectBuilder->build($website->getId(), $product, $useAdditionalTable);
@@ -84,6 +82,13 @@ public function execute(
$stopFlags = [];
$prevKey = null;
+ $storeGroup = $this->storeManager->getGroup($website->getDefaultGroupId());
+ $currentDate = $this->localeDate->scopeDate($storeGroup->getDefaultStoreId(), null, true);
+ $previousDate = (clone $currentDate)->modify('-1 day');
+ $previousDate->setTime(23, 59, 59);
+ $nextDate = (clone $currentDate)->modify('+1 day');
+ $nextDate->setTime(0, 0, 0);
+
while ($ruleData = $productsStmt->fetch()) {
$ruleProductId = $ruleData['product_id'];
$productKey = $ruleProductId .
@@ -100,12 +105,11 @@ public function execute(
}
}
- $ruleData['from_time'] = $this->roundTime($ruleData['from_time']);
- $ruleData['to_time'] = $this->roundTime($ruleData['to_time']);
/**
* Build prices for each day
*/
- for ($time = $fromDate; $time <= $toDate; $time += IndexBuilder::SECONDS_IN_DAY) {
+ foreach ([$previousDate, $currentDate, $nextDate] as $date) {
+ $time = $date->getTimestamp();
if (($ruleData['from_time'] == 0 ||
$time >= $ruleData['from_time']) && ($ruleData['to_time'] == 0 ||
$time <= $ruleData['to_time'])
@@ -118,7 +122,7 @@ public function execute(
if (!isset($dayPrices[$priceKey])) {
$dayPrices[$priceKey] = [
- 'rule_date' => $time,
+ 'rule_date' => $date,
'website_id' => $ruleData['website_id'],
'customer_group_id' => $ruleData['customer_group_id'],
'product_id' => $ruleProductId,
@@ -151,18 +155,7 @@ public function execute(
}
$this->pricesPersistor->execute($dayPrices, $useAdditionalTable);
}
- return true;
- }
- /**
- * @param int $timeStamp
- * @return int
- */
- private function roundTime($timeStamp)
- {
- if (is_numeric($timeStamp) && $timeStamp != 0) {
- $timeStamp = $this->dateTime->timestamp($this->dateTime->date('Y-m-d 00:00:00', $timeStamp));
- }
- return $timeStamp;
+ return true;
}
}
diff --git a/app/code/Magento/CatalogRule/Model/ResourceModel/Product/CollectionProcessor.php b/app/code/Magento/CatalogRule/Model/ResourceModel/Product/CollectionProcessor.php
index 0dee9eda5b6e8..1fd6f0cbc986f 100644
--- a/app/code/Magento/CatalogRule/Model/ResourceModel/Product/CollectionProcessor.php
+++ b/app/code/Magento/CatalogRule/Model/ResourceModel/Product/CollectionProcessor.php
@@ -90,7 +90,7 @@ public function addPriceData(ProductCollection $productCollection, $joinColumn =
),
$connection->quoteInto(
'catalog_rule.rule_date = ?',
- $this->dateTime->formatDate($this->localeDate->date(null, null, false), false)
+ $this->dateTime->formatDate($this->localeDate->scopeDate($store->getId()), false)
),
]
),
diff --git a/app/code/Magento/CatalogRule/Model/ResourceModel/Product/LinkedProductSelectBuilderByCatalogRulePrice.php b/app/code/Magento/CatalogRule/Model/ResourceModel/Product/LinkedProductSelectBuilderByCatalogRulePrice.php
index 02d2631058a1a..48c463fc18b80 100644
--- a/app/code/Magento/CatalogRule/Model/ResourceModel/Product/LinkedProductSelectBuilderByCatalogRulePrice.php
+++ b/app/code/Magento/CatalogRule/Model/ResourceModel/Product/LinkedProductSelectBuilderByCatalogRulePrice.php
@@ -88,7 +88,8 @@ public function __construct(
*/
public function build($productId)
{
- $currentDate = $this->dateTime->formatDate($this->localeDate->date(null, null, false), false);
+ $timestamp = $this->localeDate->scopeTimeStamp($this->storeManager->getStore());
+ $currentDate = $this->dateTime->formatDate($timestamp, false);
$linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField();
$productTable = $this->resource->getTableName('catalog_product_entity');
diff --git a/app/code/Magento/CatalogRule/Observer/PrepareCatalogProductCollectionPricesObserver.php b/app/code/Magento/CatalogRule/Observer/PrepareCatalogProductCollectionPricesObserver.php
index a635c5611eff6..bf0c85e671dd7 100644
--- a/app/code/Magento/CatalogRule/Observer/PrepareCatalogProductCollectionPricesObserver.php
+++ b/app/code/Magento/CatalogRule/Observer/PrepareCatalogProductCollectionPricesObserver.php
@@ -105,7 +105,7 @@ public function execute(\Magento\Framework\Event\Observer $observer)
if ($observer->getEvent()->hasDate()) {
$date = new \DateTime($observer->getEvent()->getDate());
} else {
- $date = $this->localeDate->date(null, null, false);
+ $date = (new \DateTime())->setTimestamp($this->localeDate->scopeTimeStamp($store));
}
$productIds = [];
diff --git a/app/code/Magento/CatalogRule/Observer/ProcessAdminFinalPriceObserver.php b/app/code/Magento/CatalogRule/Observer/ProcessAdminFinalPriceObserver.php
index 2fd23ae391474..89ed519cfb8c8 100644
--- a/app/code/Magento/CatalogRule/Observer/ProcessAdminFinalPriceObserver.php
+++ b/app/code/Magento/CatalogRule/Observer/ProcessAdminFinalPriceObserver.php
@@ -65,7 +65,8 @@ public function __construct(
public function execute(\Magento\Framework\Event\Observer $observer)
{
$product = $observer->getEvent()->getProduct();
- $date = $this->localeDate->date(null, null, false);
+ $storeId = $product->getStoreId();
+ $date = $this->localeDate->scopeDate($storeId);
$key = false;
$ruleData = $this->coreRegistry->registry('rule_data');
diff --git a/app/code/Magento/CatalogRule/Observer/ProcessFrontFinalPriceObserver.php b/app/code/Magento/CatalogRule/Observer/ProcessFrontFinalPriceObserver.php
index b27768ae091ed..075fe9e51f7dc 100644
--- a/app/code/Magento/CatalogRule/Observer/ProcessFrontFinalPriceObserver.php
+++ b/app/code/Magento/CatalogRule/Observer/ProcessFrontFinalPriceObserver.php
@@ -80,7 +80,7 @@ public function execute(\Magento\Framework\Event\Observer $observer)
if ($observer->hasDate()) {
$date = new \DateTime($observer->getEvent()->getDate());
} else {
- $date = $this->localeDate->date(null, null, false);
+ $date = $this->localeDate->scopeDate($storeId);
}
if ($observer->hasWebsiteId()) {
diff --git a/app/code/Magento/CatalogRule/Pricing/Price/CatalogRulePrice.php b/app/code/Magento/CatalogRule/Pricing/Price/CatalogRulePrice.php
index 8bce5456ffa72..7cbbc547571ab 100644
--- a/app/code/Magento/CatalogRule/Pricing/Price/CatalogRulePrice.php
+++ b/app/code/Magento/CatalogRule/Pricing/Price/CatalogRulePrice.php
@@ -88,7 +88,7 @@ public function getValue()
$this->value = (float)$this->product->getData(self::PRICE_CODE) ?: false;
} else {
$this->value = $this->ruleResource->getRulePrice(
- $this->dateTime->date(null, null, false),
+ $this->dateTime->scopeDate($this->storeManager->getStore()->getId()),
$this->storeManager->getStore()->getWebsiteId(),
$this->customerSession->getCustomerGroupId(),
$this->product->getId()
diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexBuilderTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexBuilderTest.php
index 920dcb8e1ede5..78668366bccdc 100644
--- a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexBuilderTest.php
+++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/IndexBuilderTest.php
@@ -252,7 +252,7 @@ public function testUpdateCatalogRuleGroupWebsiteData()
);
$resourceMock->expects($this->any())
->method('getMainTable')
- ->will($this->returnValue('catalog_product_entity_tear_price'));
+ ->will($this->returnValue('catalog_product_entity_tier_price'));
$backendModelMock->expects($this->any())
->method('getResource')
->will($this->returnValue($resourceMock));
diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductPriceTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductPriceTest.php
index 6d7f0673ed281..5f63283df6760 100644
--- a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductPriceTest.php
+++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductPriceTest.php
@@ -6,65 +6,62 @@
namespace Magento\CatalogRule\Test\Unit\Model\Indexer;
-use Magento\CatalogRule\Model\Indexer\IndexBuilder;
+use Magento\Catalog\Model\Product;
+use Magento\CatalogRule\Model\Indexer\ProductPriceCalculator;
+use Magento\CatalogRule\Model\Indexer\ReindexRuleProductPrice;
+use Magento\CatalogRule\Model\Indexer\RuleProductPricesPersistor;
+use Magento\CatalogRule\Model\Indexer\RuleProductsSelectBuilder;
+use Magento\Framework\Stdlib\DateTime\TimezoneInterface;
+use Magento\Store\Api\Data\GroupInterface;
+use Magento\Store\Api\Data\WebsiteInterface;
+use Magento\Store\Model\StoreManagerInterface;
+use PHPUnit\Framework\MockObject\MockObject;
class ReindexRuleProductPriceTest extends \PHPUnit\Framework\TestCase
{
/**
- * @var \Magento\CatalogRule\Model\Indexer\ReindexRuleProductPrice
+ * @var ReindexRuleProductPrice
*/
private $model;
/**
- * @var \Magento\Store\Model\StoreManagerInterface|\PHPUnit_Framework_MockObject_MockObject
+ * @var StoreManagerInterface|MockObject
*/
private $storeManagerMock;
/**
- * @var \Magento\CatalogRule\Model\Indexer\RuleProductsSelectBuilder|\PHPUnit_Framework_MockObject_MockObject
+ * @var RuleProductsSelectBuilder|MockObject
*/
private $ruleProductsSelectBuilderMock;
/**
- * @var \Magento\CatalogRule\Model\Indexer\ProductPriceCalculator|\PHPUnit_Framework_MockObject_MockObject
+ * @var ProductPriceCalculator|MockObject
*/
private $productPriceCalculatorMock;
/**
- * @var \Magento\Framework\Stdlib\DateTime\DateTime|\PHPUnit_Framework_MockObject_MockObject
+ * @var TimezoneInterface|MockObject
*/
- private $dateTimeMock;
+ private $localeDate;
/**
- * @var \Magento\CatalogRule\Model\Indexer\RuleProductPricesPersistor|\PHPUnit_Framework_MockObject_MockObject
+ * @var RuleProductPricesPersistor|MockObject
*/
private $pricesPersistorMock;
protected function setUp()
{
- $this->storeManagerMock = $this->getMockBuilder(\Magento\Store\Model\StoreManagerInterface::class)
- ->disableOriginalConstructor()
- ->getMock();
- $this->ruleProductsSelectBuilderMock =
- $this->getMockBuilder(\Magento\CatalogRule\Model\Indexer\RuleProductsSelectBuilder::class)
- ->disableOriginalConstructor()
- ->getMock();
- $this->productPriceCalculatorMock =
- $this->getMockBuilder(\Magento\CatalogRule\Model\Indexer\ProductPriceCalculator::class)
- ->disableOriginalConstructor()
- ->getMock();
- $this->dateTimeMock = $this->getMockBuilder(\Magento\Framework\Stdlib\DateTime\DateTime::class)
- ->disableOriginalConstructor()
- ->getMock();
- $this->pricesPersistorMock =
- $this->getMockBuilder(\Magento\CatalogRule\Model\Indexer\RuleProductPricesPersistor::class)
- ->disableOriginalConstructor()
- ->getMock();
- $this->model = new \Magento\CatalogRule\Model\Indexer\ReindexRuleProductPrice(
+ $this->storeManagerMock = $this->createMock(StoreManagerInterface::class);
+ $this->ruleProductsSelectBuilderMock = $this->createMock(RuleProductsSelectBuilder::class);
+ $this->productPriceCalculatorMock = $this->createMock(ProductPriceCalculator::class);
+ $this->localeDate = $this->createMock(TimezoneInterface::class);
+ $this->pricesPersistorMock = $this->createMock(RuleProductPricesPersistor::class);
+
+ $this->model = new ReindexRuleProductPrice(
$this->storeManagerMock,
$this->ruleProductsSelectBuilderMock,
$this->productPriceCalculatorMock,
- $this->dateTimeMock,
+ $this->localeDate,
$this->pricesPersistorMock
);
}
@@ -72,19 +69,32 @@ protected function setUp()
public function testExecute()
{
$websiteId = 234;
- $productMock = $this->getMockBuilder(\Magento\Catalog\Model\Product::class)
- ->disableOriginalConstructor()
- ->getMock();
-
- $websiteMock = $this->getMockBuilder(\Magento\Store\Api\Data\WebsiteInterface::class)
- ->disableOriginalConstructor()
- ->getMock();
- $websiteMock->expects($this->once())->method('getId')->willReturn($websiteId);
- $this->storeManagerMock->expects($this->once())->method('getWebsites')->willReturn([$websiteMock]);
-
- $statementMock = $this->getMockBuilder(\Zend_Db_Statement_Interface::class)
- ->disableOriginalConstructor()
- ->getMock();
+ $defaultGroupId = 11;
+ $defaultStoreId = 22;
+
+ $websiteMock = $this->createMock(WebsiteInterface::class);
+ $websiteMock->expects($this->once())
+ ->method('getId')
+ ->willReturn($websiteId);
+ $websiteMock->expects($this->once())
+ ->method('getDefaultGroupId')
+ ->willReturn($defaultGroupId);
+ $this->storeManagerMock->expects($this->once())
+ ->method('getWebsites')
+ ->willReturn([$websiteMock]);
+ $groupMock = $this->createMock(GroupInterface::class);
+ $groupMock->method('getId')
+ ->willReturn($defaultStoreId);
+ $groupMock->expects($this->once())
+ ->method('getDefaultStoreId')
+ ->willReturn($defaultStoreId);
+ $this->storeManagerMock->expects($this->once())
+ ->method('getGroup')
+ ->with($defaultGroupId)
+ ->willReturn($groupMock);
+
+ $productMock = $this->createMock(Product::class);
+ $statementMock = $this->createMock(\Zend_Db_Statement_Interface::class);
$this->ruleProductsSelectBuilderMock->expects($this->once())
->method('build')
->with($websiteId, $productMock, true)
@@ -99,29 +109,22 @@ public function testExecute()
'action_stop' => true
];
- $this->dateTimeMock->expects($this->at(0))
- ->method('date')
- ->with('Y-m-d 00:00:00', $ruleData['from_time'])
- ->willReturn($ruleData['from_time']);
- $this->dateTimeMock->expects($this->at(1))
- ->method('timestamp')
- ->with($ruleData['from_time'])
- ->willReturn($ruleData['from_time']);
-
- $this->dateTimeMock->expects($this->at(2))
- ->method('date')
- ->with('Y-m-d 00:00:00', $ruleData['to_time'])
- ->willReturn($ruleData['to_time']);
- $this->dateTimeMock->expects($this->at(3))
- ->method('timestamp')
- ->with($ruleData['to_time'])
- ->willReturn($ruleData['to_time']);
-
- $statementMock->expects($this->at(0))->method('fetch')->willReturn($ruleData);
- $statementMock->expects($this->at(1))->method('fetch')->willReturn(false);
-
- $this->productPriceCalculatorMock->expects($this->atLeastOnce())->method('calculate');
- $this->pricesPersistorMock->expects($this->once())->method('execute');
+ $this->localeDate->expects($this->once())
+ ->method('scopeDate')
+ ->with($defaultStoreId, null, true)
+ ->willReturn(new \DateTime());
+
+ $statementMock->expects($this->at(0))
+ ->method('fetch')
+ ->willReturn($ruleData);
+ $statementMock->expects($this->at(1))
+ ->method('fetch')
+ ->willReturn(false);
+
+ $this->productPriceCalculatorMock->expects($this->atLeastOnce())
+ ->method('calculate');
+ $this->pricesPersistorMock->expects($this->once())
+ ->method('execute');
$this->assertTrue($this->model->execute(1, $productMock, true));
}
diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductTest.php
index 0dbbaee8d2871..a86ab736fb289 100644
--- a/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductTest.php
+++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Indexer/ReindexRuleProductTest.php
@@ -8,89 +8,96 @@
use Magento\Catalog\Model\ResourceModel\Indexer\ActiveTableSwitcher;
use Magento\CatalogRule\Model\Indexer\IndexerTableSwapperInterface;
+use Magento\CatalogRule\Model\Indexer\ReindexRuleProduct;
+use Magento\CatalogRule\Model\Rule;
+use Magento\Framework\App\ResourceConnection;
+use Magento\Framework\DB\Adapter\AdapterInterface;
+use Magento\Framework\Stdlib\DateTime\TimezoneInterface;
+use Magento\Store\Model\ScopeInterface;
+use PHPUnit\Framework\MockObject\MockObject;
class ReindexRuleProductTest extends \PHPUnit\Framework\TestCase
{
/**
- * @var \Magento\CatalogRule\Model\Indexer\ReindexRuleProduct
+ * @var ReindexRuleProduct
*/
private $model;
/**
- * @var \Magento\Framework\App\ResourceConnection|\PHPUnit_Framework_MockObject_MockObject
+ * @var ResourceConnection|MockObject
*/
private $resourceMock;
/**
- * @var ActiveTableSwitcher|\PHPUnit_Framework_MockObject_MockObject
+ * @var ActiveTableSwitcher|MockObject
*/
private $activeTableSwitcherMock;
/**
- * @var IndexerTableSwapperInterface|\PHPUnit_Framework_MockObject_MockObject
+ * @var IndexerTableSwapperInterface|MockObject
*/
private $tableSwapperMock;
+ /**
+ * @var TimezoneInterface|MockObject
+ */
+ private $localeDateMock;
+
protected function setUp()
{
- $this->resourceMock = $this->getMockBuilder(\Magento\Framework\App\ResourceConnection::class)
- ->disableOriginalConstructor()
- ->getMock();
- $this->activeTableSwitcherMock = $this->getMockBuilder(ActiveTableSwitcher::class)
- ->disableOriginalConstructor()
- ->getMock();
- $this->tableSwapperMock = $this->getMockForAbstractClass(
- IndexerTableSwapperInterface::class
- );
- $this->model = new \Magento\CatalogRule\Model\Indexer\ReindexRuleProduct(
+ $this->resourceMock = $this->createMock(ResourceConnection::class);
+ $this->activeTableSwitcherMock = $this->createMock(ActiveTableSwitcher::class);
+ $this->tableSwapperMock = $this->createMock(IndexerTableSwapperInterface::class);
+ $this->localeDateMock = $this->createMock(TimezoneInterface::class);
+
+ $this->model = new ReindexRuleProduct(
$this->resourceMock,
$this->activeTableSwitcherMock,
- $this->tableSwapperMock
+ $this->tableSwapperMock,
+ $this->localeDateMock
);
}
public function testExecuteIfRuleInactive()
{
- $ruleMock = $this->getMockBuilder(\Magento\CatalogRule\Model\Rule::class)
- ->disableOriginalConstructor()
- ->getMock();
- $ruleMock->expects($this->once())->method('getIsActive')->willReturn(false);
+ $ruleMock = $this->createMock(Rule::class);
+ $ruleMock->expects($this->once())
+ ->method('getIsActive')
+ ->willReturn(false);
$this->assertFalse($this->model->execute($ruleMock, 100, true));
}
public function testExecuteIfRuleWithoutWebsiteIds()
{
- $ruleMock = $this->getMockBuilder(\Magento\CatalogRule\Model\Rule::class)
- ->disableOriginalConstructor()
- ->getMock();
- $ruleMock->expects($this->once())->method('getIsActive')->willReturn(true);
- $ruleMock->expects($this->once())->method('getWebsiteIds')->willReturn(null);
+ $ruleMock = $this->createMock(Rule::class);
+ $ruleMock->expects($this->once())
+ ->method('getIsActive')
+ ->willReturn(true);
+ $ruleMock->expects($this->once())
+ ->method('getWebsiteIds')
+ ->willReturn(null);
$this->assertFalse($this->model->execute($ruleMock, 100, true));
}
public function testExecute()
{
+ $websiteId = 3;
+ $websiteTz = 'America/Los_Angeles';
$productIds = [
- 4 => [1 => 1],
- 5 => [1 => 1],
- 6 => [1 => 1],
+ 4 => [$websiteId => 1],
+ 5 => [$websiteId => 1],
+ 6 => [$websiteId => 1],
];
- $ruleMock = $this->getMockBuilder(\Magento\CatalogRule\Model\Rule::class)
- ->disableOriginalConstructor()
- ->getMock();
- $ruleMock->expects($this->once())->method('getIsActive')->willReturn(true);
- $ruleMock->expects($this->exactly(2))->method('getWebsiteIds')->willReturn(1);
- $ruleMock->expects($this->once())->method('getMatchingProductIds')->willReturn($productIds);
$this->tableSwapperMock->expects($this->once())
->method('getWorkingTableName')
->with('catalogrule_product')
->willReturn('catalogrule_product_replica');
- $connectionMock = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class)
- ->disableOriginalConstructor()
- ->getMock();
- $this->resourceMock->expects($this->at(0))->method('getConnection')->willReturn($connectionMock);
+ $connectionMock = $this->createMock(AdapterInterface::class);
+ $this->resourceMock->expects($this->at(0))
+ ->method('getConnection')
+ ->willReturn($connectionMock);
$this->resourceMock->expects($this->at(1))
->method('getTableName')
->with('catalogrule_product')
@@ -100,21 +107,30 @@ public function testExecute()
->with('catalogrule_product_replica')
->willReturn('catalogrule_product_replica');
+ $ruleMock = $this->createMock(Rule::class);
+ $ruleMock->expects($this->once())->method('getIsActive')->willReturn(true);
+ $ruleMock->expects($this->exactly(2))->method('getWebsiteIds')->willReturn([$websiteId]);
+ $ruleMock->expects($this->once())->method('getMatchingProductIds')->willReturn($productIds);
$ruleMock->expects($this->once())->method('getId')->willReturn(100);
$ruleMock->expects($this->once())->method('getCustomerGroupIds')->willReturn([10]);
- $ruleMock->expects($this->once())->method('getFromDate')->willReturn('2017-06-21');
- $ruleMock->expects($this->once())->method('getToDate')->willReturn('2017-06-30');
+ $ruleMock->expects($this->atLeastOnce())->method('getFromDate')->willReturn('2017-06-21');
+ $ruleMock->expects($this->atLeastOnce())->method('getToDate')->willReturn('2017-06-30');
$ruleMock->expects($this->once())->method('getSortOrder')->willReturn(1);
$ruleMock->expects($this->once())->method('getSimpleAction')->willReturn('simple_action');
$ruleMock->expects($this->once())->method('getDiscountAmount')->willReturn(43);
$ruleMock->expects($this->once())->method('getStopRulesProcessing')->willReturn(true);
+ $this->localeDateMock->expects($this->once())
+ ->method('getConfigTimezone')
+ ->with(ScopeInterface::SCOPE_WEBSITE, $websiteId)
+ ->willReturn($websiteTz);
+
$batchRows = [
[
'rule_id' => 100,
'from_time' => 1498028400,
'to_time' => 1498892399,
- 'website_id' => 1,
+ 'website_id' => $websiteId,
'customer_group_id' => 10,
'product_id' => 4,
'action_operator' => 'simple_action',
@@ -126,7 +142,7 @@ public function testExecute()
'rule_id' => 100,
'from_time' => 1498028400,
'to_time' => 1498892399,
- 'website_id' => 1,
+ 'website_id' => $websiteId,
'customer_group_id' => 10,
'product_id' => 5,
'action_operator' => 'simple_action',
@@ -141,7 +157,7 @@ public function testExecute()
'rule_id' => 100,
'from_time' => 1498028400,
'to_time' => 1498892399,
- 'website_id' => 1,
+ 'website_id' => $websiteId,
'customer_group_id' => 10,
'product_id' => 6,
'action_operator' => 'simple_action',
diff --git a/app/code/Magento/CatalogRule/Test/Unit/Pricing/Price/CatalogRulePriceTest.php b/app/code/Magento/CatalogRule/Test/Unit/Pricing/Price/CatalogRulePriceTest.php
index cb1a7f53f752c..7514d2bc4b5c5 100644
--- a/app/code/Magento/CatalogRule/Test/Unit/Pricing/Price/CatalogRulePriceTest.php
+++ b/app/code/Magento/CatalogRule/Test/Unit/Pricing/Price/CatalogRulePriceTest.php
@@ -112,6 +112,7 @@ protected function setUp()
*/
public function testGetValue()
{
+ $storeId = 5;
$coreWebsiteId = 2;
$productId = 4;
$customerGroupId = 3;
@@ -120,9 +121,12 @@ public function testGetValue()
$catalogRulePrice = 55.12;
$convertedPrice = 45.34;
+ $this->coreStoreMock->expects($this->once())
+ ->method('getId')
+ ->willReturn($storeId);
$this->dataTimeMock->expects($this->once())
- ->method('date')
- ->with(null, null, false)
+ ->method('scopeDate')
+ ->with($storeId)
->willReturn($date);
$this->coreStoreMock->expects($this->once())
->method('getWebsiteId')
diff --git a/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Filter/Preprocessor.php b/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Filter/Preprocessor.php
index c758e773f43c1..a97d362c5de7f 100644
--- a/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Filter/Preprocessor.php
+++ b/app/code/Magento/CatalogSearch/Model/Adapter/Mysql/Filter/Preprocessor.php
@@ -165,7 +165,10 @@ private function processQueryWithField(FilterInterface $filter, $isNegation, $qu
$this->customerSession->getCustomerGroupId()
);
} elseif ($filter->getField() === 'category_ids') {
- return 'category_ids_index.category_id = ' . (int) $filter->getValue();
+ return $this->connection->quoteInto(
+ 'category_ids_index.category_id in (?)',
+ $filter->getValue()
+ );
} elseif ($attribute->isStatic()) {
$alias = $this->aliasResolver->getAlias($filter);
$resultQuery = str_replace(
@@ -198,8 +201,9 @@ private function processQueryWithField(FilterInterface $filter, $isNegation, $qu
)
->joinLeft(
['current_store' => $table],
- 'current_store.attribute_id = main_table.attribute_id AND current_store.store_id = '
- . $currentStoreId,
+ "current_store.{$linkIdField} = main_table.{$linkIdField} AND "
+ . "current_store.attribute_id = main_table.attribute_id AND current_store.store_id = "
+ . $currentStoreId,
null
)
->columns([$filter->getField() => $ifNullCondition])
diff --git a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php
index 21d8b7297da7d..912dec8666191 100644
--- a/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php
+++ b/app/code/Magento/CatalogSearch/Model/Indexer/Fulltext.php
@@ -3,11 +3,14 @@
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/
+
namespace Magento\CatalogSearch\Model\Indexer;
use Magento\CatalogSearch\Model\Indexer\Fulltext\Action\FullFactory;
+use Magento\CatalogSearch\Model\Indexer\Scope\State;
use Magento\CatalogSearch\Model\Indexer\Scope\StateFactory;
use Magento\CatalogSearch\Model\ResourceModel\Fulltext as FulltextResource;
+use Magento\Framework\App\ObjectManager;
use Magento\Framework\Indexer\DimensionProviderInterface;
use Magento\Store\Model\StoreDimensionProvider;
use Magento\Indexer\Model\ProcessManager;
@@ -79,6 +82,7 @@ class Fulltext implements
* @param DimensionProviderInterface $dimensionProvider
* @param array $data
* @param ProcessManager $processManager
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function __construct(
FullFactory $fullActionFactory,
@@ -95,11 +99,9 @@ public function __construct(
$this->fulltextResource = $fulltextResource;
$this->data = $data;
$this->indexSwitcher = $indexSwitcher;
- $this->indexScopeState = $indexScopeStateFactory->create();
+ $this->indexScopeState = ObjectManager::getInstance()->get(State::class);
$this->dimensionProvider = $dimensionProvider;
- $this->processManager = $processManager ?: \Magento\Framework\App\ObjectManager::getInstance()->get(
- ProcessManager::class
- );
+ $this->processManager = $processManager ?: ObjectManager::getInstance()->get(ProcessManager::class);
}
/**
@@ -127,9 +129,11 @@ public function executeByDimensions(array $dimensions, \Traversable $entityIds =
throw new \InvalidArgumentException('Indexer "' . self::INDEXER_ID . '" support only Store dimension');
}
$storeId = $dimensions[StoreDimensionProvider::DIMENSION_NAME]->getValue();
- $saveHandler = $this->indexerHandlerFactory->create([
- 'data' => $this->data
- ]);
+ $saveHandler = $this->indexerHandlerFactory->create(
+ [
+ 'data' => $this->data,
+ ]
+ );
if (null === $entityIds) {
$this->indexScopeState->useTemporaryIndex();
diff --git a/app/code/Magento/CatalogSearch/Model/Layer/Filter/Decimal.php b/app/code/Magento/CatalogSearch/Model/Layer/Filter/Decimal.php
index e9fb1070fedd5..3b0c4dfb6df2f 100644
--- a/app/code/Magento/CatalogSearch/Model/Layer/Filter/Decimal.php
+++ b/app/code/Magento/CatalogSearch/Model/Layer/Filter/Decimal.php
@@ -3,6 +3,8 @@
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/
+declare(strict_types=1);
+
namespace Magento\CatalogSearch\Model\Layer\Filter;
use Magento\Catalog\Model\Layer\Filter\AbstractFilter;
@@ -12,6 +14,9 @@
*/
class Decimal extends AbstractFilter
{
+ /** Decimal delta for filter */
+ private const DECIMAL_DELTA = 0.001;
+
/**
* @var \Magento\Framework\Pricing\PriceCurrencyInterface
*/
@@ -70,11 +75,17 @@ public function apply(\Magento\Framework\App\RequestInterface $request)
list($from, $to) = explode('-', $filter);
+ // When the range is 10-20 we only need to get products that are in the 10-19.99 range.
+ $toValue = $to;
+ if (!empty($toValue) && $from !== $toValue) {
+ $toValue -= self::DECIMAL_DELTA;
+ }
+
$this->getLayer()
->getProductCollection()
->addFieldToFilter(
$this->getAttributeModel()->getAttributeCode(),
- ['from' => $from, 'to' => $to]
+ ['from' => $from, 'to' => $toValue]
);
$this->getLayer()->getState()->addFilter(
@@ -111,7 +122,7 @@ protected function _getItemsData()
$from = '';
}
if ($to == '*') {
- $to = null;
+ $to = '';
}
$label = $this->renderRangeLabel(empty($from) ? 0 : $from, $to);
$value = $from . '-' . $to;
@@ -138,7 +149,7 @@ protected function _getItemsData()
protected function renderRangeLabel($fromPrice, $toPrice)
{
$formattedFromPrice = $this->priceCurrency->format($fromPrice);
- if ($toPrice === null) {
+ if ($toPrice === '') {
return __('%1 and above', $formattedFromPrice);
} else {
if ($fromPrice != $toPrice) {
diff --git a/app/code/Magento/CatalogSearch/Model/Layer/Filter/Price.php b/app/code/Magento/CatalogSearch/Model/Layer/Filter/Price.php
index a19f53469ae01..66d9281ed38e2 100644
--- a/app/code/Magento/CatalogSearch/Model/Layer/Filter/Price.php
+++ b/app/code/Magento/CatalogSearch/Model/Layer/Filter/Price.php
@@ -3,6 +3,8 @@
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/
+declare(strict_types=1);
+
namespace Magento\CatalogSearch\Model\Layer\Filter;
use Magento\Catalog\Model\Layer\Filter\AbstractFilter;
@@ -11,6 +13,7 @@
* Layer price filter based on Search API
*
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
+ * @SuppressWarnings(PHPMD.CookieAndSessionMisuse)
*/
class Price extends AbstractFilter
{
@@ -138,7 +141,7 @@ public function apply(\Magento\Framework\App\RequestInterface $request)
list($from, $to) = $filter;
$this->getLayer()->getProductCollection()->addFieldToFilter(
- 'price',
+ $this->getAttributeModel()->getAttributeCode(),
['from' => $from, 'to' => empty($to) || $from == $to ? $to : $to - self::PRICE_DELTA]
);
diff --git a/app/code/Magento/CatalogSearch/Model/ResourceModel/Advanced/Collection.php b/app/code/Magento/CatalogSearch/Model/ResourceModel/Advanced/Collection.php
index 1946dd35b8d37..595bc12ca956a 100644
--- a/app/code/Magento/CatalogSearch/Model/ResourceModel/Advanced/Collection.php
+++ b/app/code/Magento/CatalogSearch/Model/ResourceModel/Advanced/Collection.php
@@ -391,7 +391,8 @@ private function getSearchResultApplier(SearchResultInterface $searchResult): Se
'collection' => $this,
'searchResult' => $searchResult,
/** This variable sets by serOrder method, but doesn't have a getter method. */
- 'orders' => $this->_orders
+ 'orders' => $this->_orders,
+ 'size' => $this->getPageSize(),
]
);
}
diff --git a/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext/Collection.php b/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext/Collection.php
index 4f84f3868c6a3..14305359a71b3 100644
--- a/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext/Collection.php
+++ b/app/code/Magento/CatalogSearch/Model/ResourceModel/Fulltext/Collection.php
@@ -485,12 +485,12 @@ private function getSearchCriteriaResolver(): SearchCriteriaResolverInterface
{
return $this->searchCriteriaResolverFactory->create(
[
- 'builder' => $this->getSearchCriteriaBuilder(),
- 'collection' => $this,
- 'searchRequestName' => $this->searchRequestName,
- 'currentPage' => $this->_curPage,
- 'size' => $this->getPageSize(),
- 'orders' => $this->searchOrders,
+ 'builder' => $this->getSearchCriteriaBuilder(),
+ 'collection' => $this,
+ 'searchRequestName' => $this->searchRequestName,
+ 'currentPage' => (int)$this->_curPage,
+ 'size' => $this->getPageSize(),
+ 'orders' => $this->searchOrders,
]
);
}
@@ -505,10 +505,12 @@ private function getSearchResultApplier(SearchResultInterface $searchResult): Se
{
return $this->searchResultApplierFactory->create(
[
- 'collection' => $this,
- 'searchResult' => $searchResult,
- /** This variable sets by serOrder method, but doesn't have a getter method. */
- 'orders' => $this->_orders,
+ 'collection' => $this,
+ 'searchResult' => $searchResult,
+ /** This variable sets by serOrder method, but doesn't have a getter method. */
+ 'orders' => $this->_orders,
+ 'size' => $this->getPageSize(),
+ 'currentPage' => (int)$this->_curPage,
]
);
}
diff --git a/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator.php b/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator.php
index 8f8ba39ebd329..5ac252677ff79 100644
--- a/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator.php
+++ b/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator.php
@@ -3,6 +3,8 @@
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/
+declare(strict_types=1);
+
namespace Magento\CatalogSearch\Model\Search;
use Magento\Catalog\Api\Data\EavAttributeInterface;
@@ -78,6 +80,7 @@ private function generateRequest($attributeType, $container, $useFulltext)
{
$request = [];
foreach ($this->getSearchableAttributes() as $attribute) {
+ /** @var $attribute Attribute */
if ($attribute->getData($attributeType)) {
if (!in_array($attribute->getAttributeCode(), ['price', 'category_ids'], true)) {
$queryName = $attribute->getAttributeCode() . '_query';
@@ -97,12 +100,14 @@ private function generateRequest($attributeType, $container, $useFulltext)
],
];
$bucketName = $attribute->getAttributeCode() . self::BUCKET_SUFFIX;
- $generator = $this->generatorResolver->getGeneratorForType($attribute->getBackendType());
+ $generatorType = $attribute->getFrontendInput() === 'price'
+ ? $attribute->getFrontendInput()
+ : $attribute->getBackendType();
+ $generator = $this->generatorResolver->getGeneratorForType($generatorType);
$request['filters'][$filterName] = $generator->getFilterData($attribute, $filterName);
$request['aggregations'][$bucketName] = $generator->getAggregationData($attribute, $bucketName);
}
}
- /** @var $attribute Attribute */
if (!$attribute->getIsSearchable() || in_array($attribute->getAttributeCode(), ['price'], true)) {
// Some fields have their own specific handlers
continue;
diff --git a/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator/Decimal.php b/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator/Decimal.php
index b3d39a48fe9fc..73d011cc532db 100644
--- a/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator/Decimal.php
+++ b/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator/Decimal.php
@@ -3,6 +3,7 @@
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/
+declare(strict_types=1);
namespace Magento\CatalogSearch\Model\Search\RequestGenerator;
diff --git a/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator/Price.php b/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator/Price.php
new file mode 100644
index 0000000000000..949806d14f45a
--- /dev/null
+++ b/app/code/Magento/CatalogSearch/Model/Search/RequestGenerator/Price.php
@@ -0,0 +1,46 @@
+ FilterInterface::TYPE_RANGE,
+ 'name' => $filterName,
+ 'field' => $attribute->getAttributeCode(),
+ 'from' => '$' . $attribute->getAttributeCode() . '.from$',
+ 'to' => '$' . $attribute->getAttributeCode() . '.to$',
+ ];
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getAggregationData(Attribute $attribute, $bucketName): array
+ {
+ return [
+ 'type' => BucketInterface::TYPE_DYNAMIC,
+ 'name' => $bucketName,
+ 'field' => $attribute->getAttributeCode(),
+ 'method' => '$price_dynamic_algorithm$',
+ 'metric' => [['type' => 'count']],
+ ];
+ }
+}
diff --git a/app/code/Magento/CatalogSearch/Test/Mftf/Section/AdminCatalogSearchTermIndexSection.xml b/app/code/Magento/CatalogSearch/Test/Mftf/Section/AdminCatalogSearchTermIndexSection.xml
index aa0145b9f96cd..dcaf7fb3a561d 100644
--- a/app/code/Magento/CatalogSearch/Test/Mftf/Section/AdminCatalogSearchTermIndexSection.xml
+++ b/app/code/Magento/CatalogSearch/Test/Mftf/Section/AdminCatalogSearchTermIndexSection.xml
@@ -21,5 +21,6 @@
+
\ No newline at end of file
diff --git a/app/code/Magento/CatalogSearch/Test/Mftf/Test/LayerNavigationOfCatalogSearchTest.xml b/app/code/Magento/CatalogSearch/Test/Mftf/Test/LayerNavigationOfCatalogSearchTest.xml
new file mode 100644
index 0000000000000..210b474af2e02
--- /dev/null
+++ b/app/code/Magento/CatalogSearch/Test/Mftf/Test/LayerNavigationOfCatalogSearchTest.xml
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Adapter/Mysql/Filter/PreprocessorTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Adapter/Mysql/Filter/PreprocessorTest.php
index 7e3de7534e8c4..a79ffcc33cabe 100644
--- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Adapter/Mysql/Filter/PreprocessorTest.php
+++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Adapter/Mysql/Filter/PreprocessorTest.php
@@ -129,7 +129,7 @@ protected function setUp()
->getMock();
$this->connection = $this->getMockBuilder(\Magento\Framework\DB\Adapter\AdapterInterface::class)
->disableOriginalConstructor()
- ->setMethods(['select', 'getIfNullSql', 'quote'])
+ ->setMethods(['select', 'getIfNullSql', 'quote', 'quoteInto'])
->getMockForAbstractClass();
$this->select = $this->getMockBuilder(\Magento\Framework\DB\Select::class)
->disableOriginalConstructor()
@@ -222,9 +222,10 @@ public function testProcessPrice()
public function processCategoryIdsDataProvider()
{
return [
- ['5', 'category_ids_index.category_id = 5'],
- [3, 'category_ids_index.category_id = 3'],
- ["' and 1 = 0", 'category_ids_index.category_id = 0'],
+ ['5', "category_ids_index.category_id in ('5')"],
+ [3, "category_ids_index.category_id in (3)"],
+ ["' and 1 = 0", "category_ids_index.category_id in ('\' and 1 = 0')"],
+ [['5', '10'], "category_ids_index.category_id in ('5', '10')"]
];
}
@@ -251,6 +252,12 @@ public function testProcessCategoryIds($categoryId, $expectedResult)
->with(\Magento\Catalog\Model\Product::ENTITY, 'category_ids')
->will($this->returnValue($this->attribute));
+ $this->connection
+ ->expects($this->once())
+ ->method('quoteInto')
+ ->with('category_ids_index.category_id in (?)', $categoryId)
+ ->willReturn($expectedResult);
+
$actualResult = $this->target->process($this->filter, $isNegation, $query);
$this->assertSame($expectedResult, $this->removeWhitespaces($actualResult));
}
diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/PriceTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/PriceTest.php
index abad58a6876d3..f783f75a170e3 100644
--- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/PriceTest.php
+++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Layer/Filter/PriceTest.php
@@ -3,6 +3,7 @@
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/
+declare(strict_types=1);
namespace Magento\CatalogSearch\Test\Unit\Model\Layer\Filter;
@@ -208,6 +209,12 @@ public function testApply()
$priceId = '15-50';
$requestVar = 'test_request_var';
+ $this->target->setAttributeModel($this->attribute);
+ $attributeCode = 'price';
+ $this->attribute->expects($this->any())
+ ->method('getAttributeCode')
+ ->will($this->returnValue($attributeCode));
+
$this->target->setRequestVar($requestVar);
$this->request->expects($this->exactly(1))
->method('getParam')
diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/Advanced/CollectionTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/Advanced/CollectionTest.php
index 683070c286239..f5e5a34047aff 100644
--- a/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/Advanced/CollectionTest.php
+++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/Advanced/CollectionTest.php
@@ -14,6 +14,7 @@
use Magento\CatalogSearch\Test\Unit\Model\ResourceModel\BaseCollection;
use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchResultApplierFactory;
use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\TotalRecordsResolverFactory;
+use PHPUnit\Framework\MockObject\MockObject;
/**
* Tests Magento\CatalogSearch\Model\ResourceModel\Advanced\Collection
@@ -35,32 +36,37 @@ class CollectionTest extends BaseCollection
private $advancedCollection;
/**
- * @var \Magento\Framework\Api\FilterBuilder|\PHPUnit_Framework_MockObject_MockObject
+ * @var \Magento\Framework\Api\FilterBuilder|MockObject
*/
private $filterBuilder;
/**
- * @var \Magento\Framework\Api\Search\SearchCriteriaBuilder|\PHPUnit_Framework_MockObject_MockObject
+ * @var \Magento\Framework\Api\Search\SearchCriteriaBuilder|MockObject
*/
private $criteriaBuilder;
/**
- * @var \Magento\Framework\Search\Adapter\Mysql\TemporaryStorageFactory|\PHPUnit_Framework_MockObject_MockObject
+ * @var \Magento\Framework\Search\Adapter\Mysql\TemporaryStorageFactory|MockObject
*/
private $temporaryStorageFactory;
/**
- * @var \Magento\Search\Api\SearchInterface|\PHPUnit_Framework_MockObject_MockObject
+ * @var \Magento\Search\Api\SearchInterface|MockObject
*/
private $search;
/**
- * @var \Magento\Eav\Model\Config|\PHPUnit_Framework_MockObject_MockObject
+ * @var \Magento\Eav\Model\Config|MockObject
*/
private $eavConfig;
/**
- * setUp method for CollectionTest
+ * @var SearchResultApplierFactory|MockObject
+ */
+ private $searchResultApplierFactory;
+
+ /**
+ * @inheritdoc
*/
protected function setUp()
{
@@ -97,17 +103,10 @@ protected function setUp()
->method('create')
->willReturn($searchCriteriaResolver);
- $searchResultApplier = $this->getMockBuilder(SearchResultApplierInterface::class)
- ->disableOriginalConstructor()
- ->setMethods(['apply'])
- ->getMockForAbstractClass();
- $searchResultApplierFactory = $this->getMockBuilder(SearchResultApplierFactory::class)
+ $this->searchResultApplierFactory = $this->getMockBuilder(SearchResultApplierFactory::class)
->disableOriginalConstructor()
->setMethods(['create'])
->getMock();
- $searchResultApplierFactory->expects($this->any())
- ->method('create')
- ->willReturn($searchResultApplier);
$totalRecordsResolver = $this->getMockBuilder(TotalRecordsResolverInterface::class)
->disableOriginalConstructor()
@@ -134,12 +133,15 @@ protected function setUp()
'productLimitationFactory' => $productLimitationFactoryMock,
'collectionProvider' => null,
'searchCriteriaResolverFactory' => $searchCriteriaResolverFactory,
- 'searchResultApplierFactory' => $searchResultApplierFactory,
+ 'searchResultApplierFactory' => $this->searchResultApplierFactory,
'totalRecordsResolverFactory' => $totalRecordsResolverFactory
]
);
}
+ /**
+ * Test to Load data with filter in place
+ */
public function testLoadWithFilterNoFilters()
{
$this->advancedCollection->loadWithFilter();
@@ -150,6 +152,7 @@ public function testLoadWithFilterNoFilters()
*/
public function testLike()
{
+ $pageSize = 10;
$attributeCode = 'description';
$attributeCodeId = 42;
$attribute = $this->createMock(\Magento\Catalog\Model\ResourceModel\Eav\Attribute::class);
@@ -168,6 +171,22 @@ public function testLike()
$searchResult = $this->createMock(\Magento\Framework\Api\Search\SearchResultInterface::class);
$this->search->expects($this->once())->method('search')->willReturn($searchResult);
+ $this->advancedCollection->setPageSize($pageSize);
+ $this->advancedCollection->setCurPage(0);
+
+ $searchResultApplier = $this->createMock(SearchResultApplierInterface::class);
+ $this->searchResultApplierFactory->expects($this->once())
+ ->method('create')
+ ->with(
+ [
+ 'collection' => $this->advancedCollection,
+ 'searchResult' => $searchResult,
+ 'orders' => [],
+ 'size' => $pageSize,
+ ]
+ )
+ ->willReturn($searchResultApplier);
+
// addFieldsToFilter will load filters,
// then loadWithFilter will trigger _renderFiltersBefore code in Advanced/Collection
$this->assertSame(
@@ -177,7 +196,7 @@ public function testLike()
}
/**
- * @return \PHPUnit_Framework_MockObject_MockObject
+ * @return MockObject
*/
protected function getCriteriaBuilder()
{
@@ -185,6 +204,7 @@ protected function getCriteriaBuilder()
->setMethods(['addFilter', 'create', 'setRequestName'])
->disableOriginalConstructor()
->getMock();
+
return $criteriaBuilder;
}
}
diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/Fulltext/CollectionTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/Fulltext/CollectionTest.php
index 9170b81dc3182..9b4010cfae453 100644
--- a/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/Fulltext/CollectionTest.php
+++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/ResourceModel/Fulltext/CollectionTest.php
@@ -5,6 +5,7 @@
*/
namespace Magento\CatalogSearch\Test\Unit\Model\ResourceModel\Fulltext;
+use Magento\Catalog\Model\ResourceModel\Product\Collection\ProductLimitationFactory;
use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchCriteriaResolverFactory;
use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchCriteriaResolverInterface;
use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchResultApplierFactory;
@@ -12,11 +13,12 @@
use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchResultApplierInterface;
use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\TotalRecordsResolverInterface;
use Magento\CatalogSearch\Test\Unit\Model\ResourceModel\BaseCollection;
+use PHPUnit\Framework\MockObject\MockObject;
use Magento\Framework\Search\Adapter\Mysql\TemporaryStorageFactory;
-use PHPUnit_Framework_MockObject_MockObject as MockObject;
-use Magento\Catalog\Model\ResourceModel\Product\Collection\ProductLimitationFactory;
/**
+ * Test class for Fulltext Collection
+ *
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
class CollectionTest extends BaseCollection
@@ -27,12 +29,12 @@ class CollectionTest extends BaseCollection
private $objectManager;
/**
- * @var \Magento\Framework\Search\Adapter\Mysql\TemporaryStorage|\PHPUnit_Framework_MockObject_MockObject
+ * @var \Magento\Framework\Search\Adapter\Mysql\TemporaryStorage|MockObject
*/
private $temporaryStorage;
/**
- * @var \Magento\Search\Api\SearchInterface|\PHPUnit_Framework_MockObject_MockObject
+ * @var \Magento\Search\Api\SearchInterface|MockObject
*/
private $search;
@@ -61,6 +63,11 @@ class CollectionTest extends BaseCollection
*/
private $filterBuilder;
+ /**
+ * @var SearchResultApplierFactory|MockObject
+ */
+ private $searchResultApplierFactory;
+
/**
* @var \Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection
*/
@@ -72,7 +79,7 @@ class CollectionTest extends BaseCollection
private $filter;
/**
- * setUp method for CollectionTest
+ * @inheritdoc
*/
protected function setUp()
{
@@ -115,17 +122,10 @@ protected function setUp()
->method('create')
->willReturn($searchCriteriaResolver);
- $searchResultApplier = $this->getMockBuilder(SearchResultApplierInterface::class)
- ->disableOriginalConstructor()
- ->setMethods(['apply'])
- ->getMockForAbstractClass();
- $searchResultApplierFactory = $this->getMockBuilder(SearchResultApplierFactory::class)
+ $this->searchResultApplierFactory = $this->getMockBuilder(SearchResultApplierFactory::class)
->disableOriginalConstructor()
->setMethods(['create'])
->getMock();
- $searchResultApplierFactory->expects($this->any())
- ->method('create')
- ->willReturn($searchResultApplier);
$totalRecordsResolver = $this->getMockBuilder(TotalRecordsResolverInterface::class)
->disableOriginalConstructor()
@@ -148,7 +148,7 @@ protected function setUp()
'temporaryStorageFactory' => $temporaryStorageFactory,
'productLimitationFactory' => $productLimitationFactoryMock,
'searchCriteriaResolverFactory' => $searchCriteriaResolverFactory,
- 'searchResultApplierFactory' => $searchResultApplierFactory,
+ 'searchResultApplierFactory' => $this->searchResultApplierFactory,
'totalRecordsResolverFactory' => $totalRecordsResolverFactory,
]
);
@@ -161,6 +161,9 @@ protected function setUp()
$this->model->setFilterBuilder($this->filterBuilder);
}
+ /**
+ * @inheritdoc
+ */
protected function tearDown()
{
$reflectionProperty = new \ReflectionProperty(\Magento\Framework\App\ObjectManager::class, '_instance');
@@ -168,16 +171,49 @@ protected function tearDown()
$reflectionProperty->setValue(null);
}
+ /**
+ * Test to Return field faceted data from faceted search result
+ */
public function testGetFacetedDataWithEmptyAggregations()
{
+ $pageSize = 10;
+
$searchResult = $this->getMockBuilder(\Magento\Framework\Api\Search\SearchResultInterface::class)
->getMockForAbstractClass();
$this->search->expects($this->once())
->method('search')
->willReturn($searchResult);
+
+ $searchResultApplier = $this->getMockBuilder(SearchResultApplierInterface::class)
+ ->disableOriginalConstructor()
+ ->setMethods(['apply'])
+ ->getMockForAbstractClass();
+ $this->searchResultApplierFactory->expects($this->any())
+ ->method('create')
+ ->willReturn($searchResultApplier);
+
+ $this->model->setPageSize($pageSize);
+ $this->model->setCurPage(0);
+
+ $this->searchResultApplierFactory->expects($this->once())
+ ->method('create')
+ ->with(
+ [
+ 'collection' => $this->model,
+ 'searchResult' => $searchResult,
+ 'orders' => [],
+ 'size' => $pageSize,
+ 'currentPage' => 0,
+ ]
+ )
+ ->willReturn($searchResultApplier);
+
$this->model->getFacetedData('field');
}
+ /**
+ * Test to Apply attribute filter to facet collection
+ */
public function testAddFieldToFilter()
{
$this->filter = $this->createFilter();
@@ -220,6 +256,7 @@ protected function getCriteriaBuilder()
protected function getFilterBuilder()
{
$filterBuilder = $this->createMock(\Magento\Framework\Api\FilterBuilder::class);
+
return $filterBuilder;
}
@@ -241,6 +278,7 @@ protected function addFiltersToFilterBuilder(MockObject $filterBuilder, array $f
->with($value)
->willReturnSelf();
}
+
return $filterBuilder;
}
@@ -252,6 +290,7 @@ protected function createFilter()
$filter = $this->getMockBuilder(\Magento\Framework\Api\Filter::class)
->disableOriginalConstructor()
->getMock();
+
return $filter;
}
}
diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/RequestGenerator/DecimalTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/RequestGenerator/DecimalTest.php
index 8157c1fa8fa82..350344372612a 100644
--- a/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/RequestGenerator/DecimalTest.php
+++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/RequestGenerator/DecimalTest.php
@@ -3,6 +3,7 @@
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/
+declare(strict_types=1);
namespace Magento\CatalogSearch\Test\Unit\Model\Search\RequestGenerator;
@@ -11,6 +12,9 @@
use Magento\Framework\Search\Request\BucketInterface;
use Magento\Framework\Search\Request\FilterInterface;
+/**
+ * Test catalog search range request generator.
+ */
class DecimalTest extends \PHPUnit\Framework\TestCase
{
/** @var Decimal */
diff --git a/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/RequestGenerator/PriceTest.php b/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/RequestGenerator/PriceTest.php
new file mode 100644
index 0000000000000..3635430197591
--- /dev/null
+++ b/app/code/Magento/CatalogSearch/Test/Unit/Model/Search/RequestGenerator/PriceTest.php
@@ -0,0 +1,82 @@
+attribute = $this->getMockBuilder(Attribute::class)
+ ->disableOriginalConstructor()
+ ->setMethods(['getAttributeCode'])
+ ->getMockForAbstractClass();
+ $this->scopeConfigMock = $this->getMockBuilder(ScopeConfigInterface::class)
+ ->setMethods(['getValue'])
+ ->getMockForAbstractClass();
+ $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this);
+ $this->price = $objectManager->getObject(
+ Price::class,
+ ['scopeConfig' => $this->scopeConfigMock]
+ );
+ }
+
+ public function testGetFilterData()
+ {
+ $filterName = 'test_filter_name';
+ $attributeCode = 'test_attribute_code';
+ $expected = [
+ 'type' => FilterInterface::TYPE_RANGE,
+ 'name' => $filterName,
+ 'field' => $attributeCode,
+ 'from' => '$' . $attributeCode . '.from$',
+ 'to' => '$' . $attributeCode . '.to$',
+ ];
+ $this->attribute->expects($this->atLeastOnce())
+ ->method('getAttributeCode')
+ ->willReturn($attributeCode);
+ $actual = $this->price->getFilterData($this->attribute, $filterName);
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function testGetAggregationData()
+ {
+ $bucketName = 'test_bucket_name';
+ $attributeCode = 'test_attribute_code';
+ $method = 'price_dynamic_algorithm';
+ $expected = [
+ 'type' => BucketInterface::TYPE_DYNAMIC,
+ 'name' => $bucketName,
+ 'field' => $attributeCode,
+ 'method' => '$'. $method . '$',
+ 'metric' => [['type' => 'count']],
+ ];
+ $this->attribute->expects($this->atLeastOnce())
+ ->method('getAttributeCode')
+ ->willReturn($attributeCode);
+ $actual = $this->price->getAggregationData($this->attribute, $bucketName);
+ $this->assertEquals($expected, $actual);
+ }
+}
diff --git a/app/code/Magento/CatalogSearch/etc/di.xml b/app/code/Magento/CatalogSearch/etc/di.xml
index 28d5035308dee..da0a60dad1f77 100644
--- a/app/code/Magento/CatalogSearch/etc/di.xml
+++ b/app/code/Magento/CatalogSearch/etc/di.xml
@@ -281,6 +281,7 @@
\Magento\CatalogSearch\Model\Search\RequestGenerator\General
- Magento\CatalogSearch\Model\Search\RequestGenerator\Decimal
+ - Magento\CatalogSearch\Model\Search\RequestGenerator\Price
diff --git a/app/code/Magento/CatalogUrlRewrite/Observer/AfterImportDataObserver.php b/app/code/Magento/CatalogUrlRewrite/Observer/AfterImportDataObserver.php
index 33c0cafc8f081..704b60a8aaf2a 100644
--- a/app/code/Magento/CatalogUrlRewrite/Observer/AfterImportDataObserver.php
+++ b/app/code/Magento/CatalogUrlRewrite/Observer/AfterImportDataObserver.php
@@ -299,12 +299,16 @@ protected function _populateForUrlGeneration($rowData)
*/
private function isNeedToPopulateForUrlGeneration($rowData, $newSku, $oldSku): bool
{
- if ((empty($newSku) || !isset($newSku['entity_id']))
- || ($this->import->getRowScope($rowData) == ImportProduct::SCOPE_STORE
- && empty($rowData[self::URL_KEY_ATTRIBUTE_CODE]))
- || (array_key_exists($rowData[ImportProduct::COL_SKU], $oldSku)
- && !isset($rowData[self::URL_KEY_ATTRIBUTE_CODE])
- && $this->import->getBehavior() === ImportExport::BEHAVIOR_APPEND)) {
+ if ((
+ (empty($newSku) || !isset($newSku['entity_id']))
+ || ($this->import->getRowScope($rowData) == ImportProduct::SCOPE_STORE
+ && empty($rowData[self::URL_KEY_ATTRIBUTE_CODE]))
+ || (array_key_exists(strtolower($rowData[ImportProduct::COL_SKU]), $oldSku)
+ && !isset($rowData[self::URL_KEY_ATTRIBUTE_CODE])
+ && $this->import->getBehavior() === ImportExport::BEHAVIOR_APPEND)
+ )
+ && !isset($rowData["categories"])
+ ) {
return false;
}
return true;
@@ -477,7 +481,7 @@ protected function currentUrlRewritesRegenerate()
$url = $currentUrlRewrite->getIsAutogenerated()
? $this->generateForAutogenerated($currentUrlRewrite, $category)
: $this->generateForCustom($currentUrlRewrite, $category);
- $urlRewrites = array_merge($urlRewrites, $url);
+ $urlRewrites = $url + $urlRewrites;
}
$this->product = null;
diff --git a/app/code/Magento/CatalogUrlRewrite/Setup/Patch/Data/UpdateUrlKeySearchable.php b/app/code/Magento/CatalogUrlRewrite/Setup/Patch/Data/UpdateUrlKeySearchable.php
new file mode 100644
index 0000000000000..75f88a8573069
--- /dev/null
+++ b/app/code/Magento/CatalogUrlRewrite/Setup/Patch/Data/UpdateUrlKeySearchable.php
@@ -0,0 +1,79 @@
+moduleDataSetup = $moduleDataSetup;
+ $this->categorySetupFactory = $categorySetupFactory;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function apply()
+ {
+ /** @var CategorySetup $categorySetup */
+ $categorySetup = $this->categorySetupFactory->create(['setup' => $this->moduleDataSetup]);
+
+ $categorySetup->updateAttribute(
+ \Magento\Catalog\Model\Product::ENTITY,
+ 'url_key',
+ 'is_searchable',
+ true
+ );
+
+ $categorySetup->updateAttribute(
+ \Magento\Catalog\Model\Category::ENTITY,
+ 'url_key',
+ 'is_searchable',
+ true
+ );
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public static function getDependencies()
+ {
+ return [CreateUrlAttributes::class];
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getAliases()
+ {
+ return [];
+ }
+}
diff --git a/app/code/Magento/CatalogUrlRewriteGraphQl/Model/Resolver/CategoryUrlSuffix.php b/app/code/Magento/CatalogUrlRewriteGraphQl/Model/Resolver/CategoryUrlSuffix.php
new file mode 100644
index 0000000000000..59708d90c23b7
--- /dev/null
+++ b/app/code/Magento/CatalogUrlRewriteGraphQl/Model/Resolver/CategoryUrlSuffix.php
@@ -0,0 +1,82 @@
+scopeConfig = $scopeConfig;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function resolve(
+ Field $field,
+ $context,
+ ResolveInfo $info,
+ array $value = null,
+ array $args = null
+ ): string {
+ /** @var StoreInterface $store */
+ $store = $context->getExtensionAttributes()->getStore();
+ $storeId = (int)$store->getId();
+ return $this->getCategoryUrlSuffix($storeId);
+ }
+
+ /**
+ * Retrieve category url suffix by store
+ *
+ * @param int $storeId
+ * @return string
+ */
+ private function getCategoryUrlSuffix(int $storeId): string
+ {
+ if (!isset($this->categoryUrlSuffix[$storeId])) {
+ $this->categoryUrlSuffix[$storeId] = $this->scopeConfig->getValue(
+ self::$xml_path_category_url_suffix,
+ ScopeInterface::SCOPE_STORE,
+ $storeId
+ );
+ }
+ return $this->categoryUrlSuffix[$storeId];
+ }
+}
diff --git a/app/code/Magento/CatalogUrlRewriteGraphQl/Model/Resolver/ProductUrlSuffix.php b/app/code/Magento/CatalogUrlRewriteGraphQl/Model/Resolver/ProductUrlSuffix.php
new file mode 100644
index 0000000000000..9a0193ba36367
--- /dev/null
+++ b/app/code/Magento/CatalogUrlRewriteGraphQl/Model/Resolver/ProductUrlSuffix.php
@@ -0,0 +1,82 @@
+scopeConfig = $scopeConfig;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function resolve(
+ Field $field,
+ $context,
+ ResolveInfo $info,
+ array $value = null,
+ array $args = null
+ ): string {
+ /** @var StoreInterface $store */
+ $store = $context->getExtensionAttributes()->getStore();
+ $storeId = (int)$store->getId();
+ return $this->getProductUrlSuffix($storeId);
+ }
+
+ /**
+ * Retrieve product url suffix by store
+ *
+ * @param int $storeId
+ * @return string
+ */
+ private function getProductUrlSuffix(int $storeId): string
+ {
+ if (!isset($this->productUrlSuffix[$storeId])) {
+ $this->productUrlSuffix[$storeId] = $this->scopeConfig->getValue(
+ self::$xml_path_product_url_suffix,
+ ScopeInterface::SCOPE_STORE,
+ $storeId
+ );
+ }
+ return $this->productUrlSuffix[$storeId];
+ }
+}
diff --git a/app/code/Magento/CatalogUrlRewriteGraphQl/composer.json b/app/code/Magento/CatalogUrlRewriteGraphQl/composer.json
index e276da0cc6fd8..202c573c2ae04 100644
--- a/app/code/Magento/CatalogUrlRewriteGraphQl/composer.json
+++ b/app/code/Magento/CatalogUrlRewriteGraphQl/composer.json
@@ -4,6 +4,7 @@
"type": "magento2-module",
"require": {
"php": "~7.1.3||~7.2.0||~7.3.0",
+ "magento/module-store": "*",
"magento/module-catalog": "*",
"magento/framework": "*"
},
diff --git a/app/code/Magento/CatalogUrlRewriteGraphQl/etc/di.xml b/app/code/Magento/CatalogUrlRewriteGraphQl/etc/di.xml
index 20e6b7e9c0053..e99f89477e807 100644
--- a/app/code/Magento/CatalogUrlRewriteGraphQl/etc/di.xml
+++ b/app/code/Magento/CatalogUrlRewriteGraphQl/etc/di.xml
@@ -14,4 +14,12 @@
+
+
+
+
+ - url_key
+
+
+
diff --git a/app/code/Magento/CatalogUrlRewriteGraphQl/etc/schema.graphqls b/app/code/Magento/CatalogUrlRewriteGraphQl/etc/schema.graphqls
index 89108e578d673..82facf6959f3c 100644
--- a/app/code/Magento/CatalogUrlRewriteGraphQl/etc/schema.graphqls
+++ b/app/code/Magento/CatalogUrlRewriteGraphQl/etc/schema.graphqls
@@ -3,15 +3,24 @@
interface ProductInterface {
url_key: String @doc(description: "The part of the URL that identifies the product")
+ url_suffix: String @doc(description: "The part of the product URL that is appended after the url key") @resolver(class: "Magento\\CatalogUrlRewriteGraphQl\\Model\\Resolver\\ProductUrlSuffix")
url_path: String @deprecated(reason: "Use product's `canonical_url` or url rewrites instead")
url_rewrites: [UrlRewrite] @doc(description: "URL rewrites list") @resolver(class: "Magento\\UrlRewriteGraphQl\\Model\\Resolver\\UrlRewrite")
}
+interface CategoryInterface {
+ url_suffix: String @doc(description: "The part of the category URL that is appended after the url key") @resolver(class: "Magento\\CatalogUrlRewriteGraphQl\\Model\\Resolver\\CategoryUrlSuffix")
+}
+
input ProductFilterInput {
url_key: FilterTypeInput @doc(description: "The part of the URL that identifies the product")
url_path: FilterTypeInput @deprecated(reason: "Use product's `canonical_url` or url rewrites instead")
}
+input ProductAttributeFilterInput {
+ url_key: FilterEqualTypeInput @doc(description: "The part of the URL that identifies the product")
+}
+
input ProductSortInput {
url_key: SortEnum @doc(description: "The part of the URL that identifies the product")
url_path: SortEnum @deprecated(reason: "Use product's `canonical_url` or url rewrites instead")
diff --git a/app/code/Magento/CatalogWidget/composer.json b/app/code/Magento/CatalogWidget/composer.json
index 6722d0df93752..8c1bd220a0f32 100644
--- a/app/code/Magento/CatalogWidget/composer.json
+++ b/app/code/Magento/CatalogWidget/composer.json
@@ -14,7 +14,8 @@
"magento/module-rule": "*",
"magento/module-store": "*",
"magento/module-widget": "*",
- "magento/module-wishlist": "*"
+ "magento/module-wishlist": "*",
+ "magento/module-theme": "*"
},
"type": "magento2-module",
"license": [
diff --git a/app/code/Magento/Checkout/Controller/Cart/UpdateItemQty.php b/app/code/Magento/Checkout/Controller/Cart/UpdateItemQty.php
index ac4a93e6066a4..9d17e32b2c93d 100644
--- a/app/code/Magento/Checkout/Controller/Cart/UpdateItemQty.php
+++ b/app/code/Magento/Checkout/Controller/Cart/UpdateItemQty.php
@@ -8,16 +8,25 @@
namespace Magento\Checkout\Controller\Cart;
use Magento\Checkout\Model\Cart\RequestQuantityProcessor;
+use Magento\Checkout\Model\Session as CheckoutSession;
+use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
+use Magento\Framework\App\Action\HttpPostActionInterface;
+use Magento\Framework\Data\Form\FormKey\Validator as FormKeyValidator;
use Magento\Framework\Exception\LocalizedException;
-use Magento\Checkout\Model\Session as CheckoutSession;
+use Magento\Framework\Exception\NotFoundException;
use Magento\Framework\Serialize\Serializer\Json;
-use Magento\Framework\Data\Form\FormKey\Validator as FormKeyValidator;
use Magento\Quote\Model\Quote\Item;
use Psr\Log\LoggerInterface;
-class UpdateItemQty extends \Magento\Framework\App\Action\Action
+/**
+ * UpdateItemQty ajax request
+ *
+ * @package Magento\Checkout\Controller\Cart
+ */
+class UpdateItemQty extends Action implements HttpPostActionInterface
{
+
/**
* @var RequestQuantityProcessor
*/
@@ -44,13 +53,16 @@ class UpdateItemQty extends \Magento\Framework\App\Action\Action
private $logger;
/**
- * @param Context $context,
+ * UpdateItemQty constructor
+ *
+ * @param Context $context
* @param RequestQuantityProcessor $quantityProcessor
* @param FormKeyValidator $formKeyValidator
* @param CheckoutSession $checkoutSession
* @param Json $json
* @param LoggerInterface $logger
*/
+
public function __construct(
Context $context,
RequestQuantityProcessor $quantityProcessor,
@@ -68,30 +80,26 @@ public function __construct(
}
/**
+ * Controller execute method
+ *
* @return void
*/
public function execute()
{
try {
- if (!$this->formKeyValidator->validate($this->getRequest())) {
- throw new LocalizedException(
- __('Something went wrong while saving the page. Please refresh the page and try again.')
- );
- }
+ $this->validateRequest();
+ $this->validateFormKey();
$cartData = $this->getRequest()->getParam('cart');
- if (!is_array($cartData)) {
- throw new LocalizedException(
- __('Something went wrong while saving the page. Please refresh the page and try again.')
- );
- }
+
+ $this->validateCartData($cartData);
$cartData = $this->quantityProcessor->process($cartData);
$quote = $this->checkoutSession->getQuote();
foreach ($cartData as $itemId => $itemInfo) {
$item = $quote->getItemById($itemId);
- $qty = isset($itemInfo['qty']) ? (double)$itemInfo['qty'] : 0;
+ $qty = isset($itemInfo['qty']) ? (double) $itemInfo['qty'] : 0;
if ($item) {
$this->updateItemQuantity($item, $qty);
}
@@ -111,11 +119,13 @@ public function execute()
*
* @param Item $item
* @param float $qty
+ * @return void
* @throws LocalizedException
*/
private function updateItemQuantity(Item $item, float $qty)
{
if ($qty > 0) {
+ $item->clearMessage();
$item->setQty($qty);
if ($item->getHasError()) {
@@ -145,9 +155,7 @@ private function jsonResponse(string $error = '')
*/
private function getResponseData(string $error = ''): array
{
- $response = [
- 'success' => true,
- ];
+ $response = ['success' => true];
if (!empty($error)) {
$response = [
@@ -158,4 +166,48 @@ private function getResponseData(string $error = ''): array
return $response;
}
+
+ /**
+ * Validates the Request HTTP method
+ *
+ * @return void
+ * @throws NotFoundException
+ */
+ private function validateRequest()
+ {
+ if ($this->getRequest()->isPost() === false) {
+ throw new NotFoundException(__('Page Not Found'));
+ }
+ }
+
+ /**
+ * Validates form key
+ *
+ * @return void
+ * @throws LocalizedException
+ */
+ private function validateFormKey()
+ {
+ if (!$this->formKeyValidator->validate($this->getRequest())) {
+ throw new LocalizedException(
+ __('Something went wrong while saving the page. Please refresh the page and try again.')
+ );
+ }
+ }
+
+ /**
+ * Validates cart data
+ *
+ * @param array|null $cartData
+ * @return void
+ * @throws LocalizedException
+ */
+ private function validateCartData($cartData = null)
+ {
+ if (!is_array($cartData)) {
+ throw new LocalizedException(
+ __('Something went wrong while saving the page. Please refresh the page and try again.')
+ );
+ }
+ }
}
diff --git a/app/code/Magento/Checkout/Model/Session.php b/app/code/Magento/Checkout/Model/Session.php
index f983ece3e635a..a654c78853d7a 100644
--- a/app/code/Magento/Checkout/Model/Session.php
+++ b/app/code/Magento/Checkout/Model/Session.php
@@ -17,6 +17,7 @@
* @api
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
* @SuppressWarnings(PHPMD.CookieAndSessionMisuse)
+ * @SuppressWarnings(PHPMD.TooManyFields)
*/
class Session extends \Magento\Framework\Session\SessionManager
{
@@ -46,6 +47,15 @@ class Session extends \Magento\Framework\Session\SessionManager
*/
protected $_loadInactive = false;
+ /**
+ * A flag to track when the quote is being loaded and attached to the session object.
+ *
+ * Used in trigger_recollect infinite loop detection.
+ *
+ * @var bool
+ */
+ private $isLoading = false;
+
/**
* Loaded order instance
*
@@ -227,6 +237,10 @@ public function getQuote()
$this->_eventManager->dispatch('custom_quote_process', ['checkout_session' => $this]);
if ($this->_quote === null) {
+ if ($this->isLoading) {
+ throw new \LogicException("Infinite loop detected, review the trace for the looping path");
+ }
+ $this->isLoading = true;
$quote = $this->quoteFactory->create();
if ($this->getQuoteId()) {
try {
@@ -289,6 +303,7 @@ public function getQuote()
$quote->setStore($this->_storeManager->getStore());
$this->_quote = $quote;
+ $this->isLoading = false;
}
if (!$this->isQuoteMasked() && !$this->_customerSession->isLoggedIn() && $this->getQuoteId()) {
diff --git a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/AssertStorefrontMiniCartSubtotalActionGroup.xml b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/AssertStorefrontMiniCartSubtotalActionGroup.xml
new file mode 100644
index 0000000000000..eba82860e8164
--- /dev/null
+++ b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/AssertStorefrontMiniCartSubtotalActionGroup.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/AssertStorefrontShoppingCartSummaryWithShippingActionGroup.xml b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/AssertStorefrontShoppingCartSummaryWithShippingActionGroup.xml
index e74f5c24fb4f6..fe5887bbf6f7c 100644
--- a/app/code/Magento/Checkout/Test/Mftf/ActionGroup/AssertStorefrontShoppingCartSummaryWithShippingActionGroup.xml
+++ b/app/code/Magento/Checkout/Test/Mftf/ActionGroup/AssertStorefrontShoppingCartSummaryWithShippingActionGroup.xml
@@ -16,9 +16,7 @@
-
-
-
-
+
+
diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/NoErrorCartCheckoutForProductsDeletedFromMiniCartTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/NoErrorCartCheckoutForProductsDeletedFromMiniCartTest.xml
index 1f3d9db5ca524..3c98f9177f4a7 100644
--- a/app/code/Magento/Checkout/Test/Mftf/Test/NoErrorCartCheckoutForProductsDeletedFromMiniCartTest.xml
+++ b/app/code/Magento/Checkout/Test/Mftf/Test/NoErrorCartCheckoutForProductsDeletedFromMiniCartTest.xml
@@ -47,7 +47,8 @@
-
+
+
diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontAddBundleDynamicProductToShoppingCartWithDisableMiniCartSidebarTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontAddBundleDynamicProductToShoppingCartWithDisableMiniCartSidebarTest.xml
index 9c00f2be1d60b..d67800e21afc2 100644
--- a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontAddBundleDynamicProductToShoppingCartWithDisableMiniCartSidebarTest.xml
+++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontAddBundleDynamicProductToShoppingCartWithDisableMiniCartSidebarTest.xml
@@ -109,7 +109,7 @@
-
+
diff --git a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontAddConfigurableProductToShoppingCartTest.xml b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontAddConfigurableProductToShoppingCartTest.xml
index 09608eef7178a..e3090d6cb311b 100644
--- a/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontAddConfigurableProductToShoppingCartTest.xml
+++ b/app/code/Magento/Checkout/Test/Mftf/Test/StorefrontAddConfigurableProductToShoppingCartTest.xml
@@ -20,6 +20,9 @@
+
+
+
diff --git a/app/code/Magento/Checkout/etc/adminhtml/system.xml b/app/code/Magento/Checkout/etc/adminhtml/system.xml
index 11e3ba5f3ed9a..399474a36bfc7 100644
--- a/app/code/Magento/Checkout/etc/adminhtml/system.xml
+++ b/app/code/Magento/Checkout/etc/adminhtml/system.xml
@@ -27,12 +27,14 @@
+ validate-zero-or-greater validate-digits
+ validate-zero-or-greater validate-digits
@@ -40,6 +42,7 @@
@@ -54,16 +57,18 @@
@@ -83,6 +88,7 @@
+ validate-emails
Separate by ",".
diff --git a/app/code/Magento/Checkout/etc/frontend/sections.xml b/app/code/Magento/Checkout/etc/frontend/sections.xml
index 90c2878f501cf..46dd8d9106545 100644
--- a/app/code/Magento/Checkout/etc/frontend/sections.xml
+++ b/app/code/Magento/Checkout/etc/frontend/sections.xml
@@ -41,7 +41,6 @@
-
diff --git a/app/code/Magento/Checkout/i18n/en_US.csv b/app/code/Magento/Checkout/i18n/en_US.csv
index 7f2f0b4390321..251985faf6cc4 100644
--- a/app/code/Magento/Checkout/i18n/en_US.csv
+++ b/app/code/Magento/Checkout/i18n/en_US.csv
@@ -156,8 +156,8 @@ Shipping,Shipping
"Number of Items to Display Pager","Number of Items to Display Pager"
"My Cart Link","My Cart Link"
"Display Cart Summary","Display Cart Summary"
-"Shopping Cart Sidebar","Shopping Cart Sidebar"
-"Display Shopping Cart Sidebar","Display Shopping Cart Sidebar"
+"Mini Cart","Mini Cart"
+"Display Mini Cart","Display Mini Cart"
"Number of Items to Display Scrollbar","Number of Items to Display Scrollbar"
"Maximum Number of Items to Display","Maximum Number of Items to Display"
"Payment Failed Emails","Payment Failed Emails"
diff --git a/app/code/Magento/Checkout/view/frontend/templates/cart/coupon.phtml b/app/code/Magento/Checkout/view/frontend/templates/cart/coupon.phtml
index bf8490affea0c..65dc514e476ff 100644
--- a/app/code/Magento/Checkout/view/frontend/templates/cart/coupon.phtml
+++ b/app/code/Magento/Checkout/view/frontend/templates/cart/coupon.phtml
@@ -7,10 +7,13 @@
/**
* @var \Magento\Framework\View\Element\AbstractBlock $block
*/
+
+// We should use strlen function because coupon code could be "0", converted to bool will lead to false
+$hasCouponCode = (bool) strlen($block->getCouponCode());
?>
, "openedState": "active", "saveState": false}}'
>
= $block->escapeHtml(__('Apply Discount Code')) ?>
@@ -23,7 +26,7 @@
"removeCouponSelector": "#remove-coupon",
"applyButton": "button.action.apply",
"cancelButton": "button.action.cancel"}}'>
-
+
@@ -34,14 +37,14 @@
name="coupon_code"
value="= $block->escapeHtmlAttr($block->getCouponCode()) ?>"
placeholder="= $block->escapeHtmlAttr(__('Enter discount code')) ?>"
- getCouponCode())) :?>
+
disabled="disabled"
/>
- getCouponCode())) : ?>
+
= /* @noEscape */ $block->getChildHtml('captcha') ?>
diff --git a/app/code/Magento/Checkout/view/frontend/templates/cart/form.phtml b/app/code/Magento/Checkout/view/frontend/templates/cart/form.phtml
index e1ab036c7d889..370d70c44d886 100644
--- a/app/code/Magento/Checkout/view/frontend/templates/cart/form.phtml
+++ b/app/code/Magento/Checkout/view/frontend/templates/cart/form.phtml
@@ -56,7 +56,7 @@
= $block->escapeHtml(__('Continue Shopping')) ?>
-