diff --git a/src/Adapter/MySQL.php b/src/Adapter/MySQL.php index 8f2dc279d..79989a2bd 100644 --- a/src/Adapter/MySQL.php +++ b/src/Adapter/MySQL.php @@ -310,18 +310,20 @@ protected function getFieldMapping() 'out_of_stock' => [ 'tableName' => 'stock_available', 'tableAlias' => 'sa', - 'joinCondition' => '(p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute' . + 'joinCondition' => '(p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute' . $stockCondition . ')', 'joinType' => self::LEFT_JOIN, - 'dependencyField' => 'id_attribute', + 'dependencyField' => 'id_product_attribute', + 'shareOperationJoin' => true, ], 'quantity' => [ 'tableName' => 'stock_available', 'tableAlias' => 'sa', - 'joinCondition' => '(p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute' . + 'joinCondition' => '(p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute' . $stockCondition . ')', 'joinType' => self::LEFT_JOIN, - 'dependencyField' => 'id_attribute', + 'dependencyField' => 'id_product_attribute', + 'shareOperationJoin' => true, 'aggregateFunction' => 'SUM', 'aggregateFieldName' => 'quantity', ], @@ -708,9 +710,7 @@ protected function computeWhereConditions(array $filterToTableMapping) $joinMapping = $filterToTableMapping[$operation[0]]; // If index is not the first, append to the table alias for // multi join - $selectAlias = $joinMapping['tableAlias'] . - ($operationIdx === 0 ? '' : '_' . $operationIdx) . - ($idx === 0 ? '' : '_' . $idx); + $selectAlias = $this->getOperationTableAlias($joinMapping, $operationIdx, $idx); $operation[0] = isset($joinMapping['fieldName']) ? $joinMapping['fieldName'] : $operation[0]; } @@ -800,6 +800,20 @@ protected function computeWhereConditions(array $filterToTableMapping) return $whereConditions; } + /** + * @param array $joinMapping + * @param int $operationIdx + * @param int $idx + * + * @return string + */ + private function getOperationTableAlias(array $joinMapping, $operationIdx, $idx) + { + return $joinMapping['tableAlias'] . + ($operationIdx === 0 ? '' : '_' . $operationIdx) . + ($idx === 0 || !empty($joinMapping['shareOperationJoin']) ? '' : '_' . $idx); + } + /** * Compute the joinConditions needed depending on the fields required in select, where, groupby & orderby fields * @@ -820,18 +834,15 @@ protected function computeJoinConditions(array $filterToTableMapping) foreach ($operations as $idx => $operation) { if ($this->requiresMappedTableForFilter($operation[0], $filterToTableMapping)) { $joinMapping = $filterToTableMapping[$operation[0]]; - if ($idx !== 0 || $operationIdx !== 0) { + $tableAlias = $this->getOperationTableAlias($joinMapping, $operationIdx, $idx); + if ($tableAlias !== $joinMapping['tableAlias']) { // Index is not the first, append index to tableAlias on joinCondition $joinMapping['joinCondition'] = preg_replace( - '~([\(\s=]' . $joinMapping['tableAlias'] . ')\.~', - '${1}' . - ($operationIdx === 0 ? '' : '_' . $operationIdx) . - ($idx === 0 ? '' : '_' . $idx) . - '.', + '~([\(\s=])' . $joinMapping['tableAlias'] . '\.~', + '${1}' . $tableAlias . '.', $joinMapping['joinCondition'] ); - $joinMapping['tableAlias'] .= ($operationIdx === 0 ? '' : '_' . $operationIdx) . - ($idx === 0 ? '' : '_' . $idx); + $joinMapping['tableAlias'] = $tableAlias; } $this->addJoinConditions($joinList, $joinMapping, $filterToTableMapping); diff --git a/src/Filters/Block.php b/src/Filters/Block.php index 1afa815e4..cdc45b2b7 100644 --- a/src/Filters/Block.php +++ b/src/Filters/Block.php @@ -118,6 +118,9 @@ public function getFilterBlock( // Get filters configured for the current query $filters = $this->provider->getFiltersForQuery($this->query, $idShop); + $attributeValueCounts = $this->countAttributeValues($filters, $selectedFilters, $idLang); + $featureValueCounts = $this->countFeatureValues($filters, $selectedFilters, $idLang); + $this->dataAccessor->loadFeatureValues(array_keys(array_filter($featureValueCounts)), $idLang); $filterBlocks = []; // iterate through each filter, and the get corresponding filter block @@ -143,11 +146,11 @@ public function getFilterBlock( break; case 'id_attribute_group': $filterBlocks = - array_merge($filterBlocks, $this->getAttributesBlock($filter, $selectedFilters, $idLang)); + array_merge($filterBlocks, $this->getAttributesBlock($filter, $selectedFilters, $idLang, $attributeValueCounts[$filter['id_value']] ?? [])); break; case 'id_feature': $filterBlocks = - array_merge($filterBlocks, $this->getFeaturesBlock($filter, $selectedFilters, $idLang)); + array_merge($filterBlocks, $this->getFeaturesBlock($filter, $selectedFilters, $idLang, $featureValueCounts[$filter['id_value']] ?? [])); break; case 'category': $parent = new Category($idCategory, $idLang); @@ -333,12 +336,6 @@ private function getWeightRangeBlock($filter, $selectedFilters, $nbProducts) ); list($weightBlock['min'], $weightBlock['max']) = $this->searchAdapter->getInitialPopulation()->getMinMaxValue('p.weight'); - if (empty($weightBlock['min']) && empty($weightBlock['max'])) { - // We don't need to continue, no filter available - return []; - } - - $weightBlock['value'] = !empty($selectedFilters['weight']) ? array_map('floatval', $selectedFilters['weight']) : null; $this->restorePriceAndWeightFilters( $this->searchAdapter->getInitialPopulation(), @@ -347,6 +344,13 @@ private function getWeightRangeBlock($filter, $selectedFilters, $nbProducts) $weightFilter ); + if (empty($weightBlock['min']) && empty($weightBlock['max'])) { + // We don't need to continue, no filter available + return []; + } + + $weightBlock['value'] = !empty($selectedFilters['weight']) ? array_map('floatval', $selectedFilters['weight']) : null; + return $weightBlock; } @@ -697,28 +701,15 @@ private function getManufacturersBlock($filter, $selectedFilters, $idLang) * @param array $filter * @param array $selectedFilters * @param int $idLang + * @param array $results value counts of the attribute group * * @return array */ - private function getAttributesBlock($filter, $selectedFilters, $idLang) + private function getAttributesBlock($filter, $selectedFilters, $idLang, array $results) { $attributesBlock = []; - $filteredSearchAdapter = null; $idAttributeGroup = $filter['id_value']; - if (!empty($selectedFilters['id_attribute_group'])) { - foreach ($selectedFilters['id_attribute_group'] as $key => $selectedFilter) { - if ($key == $idAttributeGroup) { - $filteredSearchAdapter = $this->searchAdapter->getFilteredSearchAdapter('with_attributes_' . $idAttributeGroup); - break; - } - } - } - - if (!$filteredSearchAdapter) { - $filteredSearchAdapter = $this->searchAdapter->getFilteredSearchAdapter(); - } - $attributesGroup = $this->dataAccessor->getAttributesGroups($idLang); if ($attributesGroup === []) { return $attributesBlock; @@ -726,11 +717,6 @@ private function getAttributesBlock($filter, $selectedFilters, $idLang) $attributes = $this->dataAccessor->getAttributes($idLang, $idAttributeGroup); - $filteredSearchAdapter->addOperationsFilter( - 'id_attribute_group_' . $idAttributeGroup, - [[['id_attribute_group', [(int) $idAttributeGroup]]]] - ); - $results = $filteredSearchAdapter->valueCount('id_attribute'); foreach ($results as $key => $values) { $idAttribute = $values['id_attribute']; if (!isset($attributes[$idAttribute])) { @@ -814,44 +800,20 @@ private function sortByKey(array $sortedReferenceArray, $array) * @param array $filter * @param array $selectedFilters * @param int $idLang + * @param array $results value counts of the feature * * @return array */ - private function getFeaturesBlock($filter, $selectedFilters, $idLang) + private function getFeaturesBlock($filter, $selectedFilters, $idLang, array $results) { $featureBlock = []; $idFeature = $filter['id_value']; - $filteredSearchAdapter = null; - - // Keep current selections while counting additional values for an AND feature facet. - if ((int) (isset($filter['filter_type']) ? $filter['filter_type'] : Converter::WIDGET_TYPE_CHECKBOX) !== Converter::WIDGET_TYPE_CHECKBOX_AND - && !empty($selectedFilters['id_feature']) - ) { - foreach ($selectedFilters['id_feature'] as $key => $selectedFilter) { - if ($key == $idFeature) { - $filteredSearchAdapter = $this->searchAdapter->getFilteredSearchAdapter('with_features_' . $idFeature); - - break; - } - } - } - - if (!$filteredSearchAdapter) { - $filteredSearchAdapter = $this->searchAdapter->getFilteredSearchAdapter(); - } $features = $this->dataAccessor->getFeatures($idLang); if (empty($features)) { return []; } - $filteredSearchAdapter->addOperationsFilter( - 'id_feature_' . $idFeature, - [[['id_feature', [(int) $idFeature]]]] - ); - - $filteredSearchAdapter->addSelectField('id_feature'); - $results = $filteredSearchAdapter->valueCount('id_feature_value'); foreach ($results as $key => $values) { $idFeatureValue = $values['id_feature_value']; $idFeature = $values['id_feature']; @@ -904,6 +866,85 @@ private function getFeaturesBlock($filter, $selectedFilters, $idLang) return $featureBlock; } + /** + * @return array value counts by attribute group ID + */ + private function countAttributeValues(array $filters, array $selectedFilters, $idLang) + { + $sharedIds = $ownIds = []; + foreach ($filters as $filter) { + if ($filter['type'] === 'id_attribute_group') { + if (isset($selectedFilters['id_attribute_group'][$filter['id_value']])) { + $ownIds[] = (int) $filter['id_value']; + } else { + $sharedIds[] = (int) $filter['id_value']; + } + } + } + + if ((empty($sharedIds) && empty($ownIds)) || $this->dataAccessor->getAttributesGroups($idLang) === []) { + return []; + } + + return $this->countFacetValues($sharedIds, $ownIds, 'id_attribute_group', 'id_attribute', 'with_attributes_'); + } + + /** + * @return array value counts by feature ID + */ + private function countFeatureValues(array $filters, array $selectedFilters, $idLang) + { + $sharedIds = $ownIds = []; + foreach ($filters as $filter) { + if ($filter['type'] === 'id_feature') { + // Keep current selections while counting additional values for an AND feature facet. + if ((int) ($filter['filter_type'] ?? Converter::WIDGET_TYPE_CHECKBOX) !== Converter::WIDGET_TYPE_CHECKBOX_AND + && isset($selectedFilters['id_feature'][$filter['id_value']]) + ) { + $ownIds[] = (int) $filter['id_value']; + } else { + $sharedIds[] = (int) $filter['id_value']; + } + } + } + + if ((empty($sharedIds) && empty($ownIds)) || empty($this->dataAccessor->getFeatures($idLang))) { + return []; + } + + return $this->countFacetValues($sharedIds, $ownIds, 'id_feature', 'id_feature_value', 'with_features_'); + } + + /** + * A facet with a selected value ignores its own selection, so it is counted in its own query. + * + * @param int[] $sharedIds facets counted with the current filters + * @param int[] $ownIds facets ignoring their own selection + * @param string $idField id_attribute_group or id_feature + * @param string $valueField id_attribute or id_feature_value + * @param string $selectionFilterPrefix prefix of the filter holding the selection of a facet + * + * @return array value counts by facet ID + */ + private function countFacetValues(array $sharedIds, array $ownIds, $idField, $valueField, $selectionFilterPrefix) + { + $queries = empty($sharedIds) ? [] : [[$this->searchAdapter->getFilteredSearchAdapter(), $sharedIds]]; + foreach ($ownIds as $id) { + $queries[] = [$this->searchAdapter->getFilteredSearchAdapter($selectionFilterPrefix . $id), [$id]]; + } + + $valueCounts = array_fill_keys(array_merge($sharedIds, $ownIds), []); + foreach ($queries as list($filteredSearchAdapter, $ids)) { + $filteredSearchAdapter->addOperationsFilter($idField . '_' . implode('_', $ids), [[[$idField, $ids]]]); + $filteredSearchAdapter->addSelectField($idField); + foreach ($filteredSearchAdapter->valueCount($valueField) as $valueCount) { + $valueCounts[$valueCount[$idField]][] = $valueCount; + } + } + + return $valueCounts; + } + /** * Natural sort multi-dimensional feature array * @@ -958,7 +999,9 @@ private function addCategoriesBlockFilters(InterfaceAdapter $filteredSearchAdapt $filteredSearchAdapter->addFilter('level_depth', [$depth + $levelDepth], '<='); } + // The upper bound on nleft is logically redundant but limits the index scan to the subtree. $filteredSearchAdapter->addFilter('nleft', [$parent->nleft], '>'); + $filteredSearchAdapter->addFilter('nleft', [$parent->nright], '<'); $filteredSearchAdapter->addFilter('nright', [$parent->nright], '<'); } diff --git a/src/Filters/DataAccessor.php b/src/Filters/DataAccessor.php index d2b29f746..3ec0faaa5 100644 --- a/src/Filters/DataAccessor.php +++ b/src/Filters/DataAccessor.php @@ -205,37 +205,56 @@ public static function isFeatureValuePositionSupported() public function getFeatureValues($idFeature, $idLang) { if (!isset($this->featureValues[$idLang][$idFeature])) { - // Initialize only the requested feature without discarding other cached features for the language. - $this->featureValues[$idLang][$idFeature] = []; - - // feature_value.position only exists from PrestaShop 9.0 (PrestaShop/PrestaShop#37042), - // and this module still supports 8.2, where ordering by it is an unknown column. - if (self::isFeatureValuePositionSupported() - && (bool) Configuration::get('PS_LAYERED_FILTER_FEATURE_VALUES_USE_POSITION') - ) { - $order = 'ORDER BY v.`position` ASC'; - } else { - $order = 'ORDER BY vl.`value` ASC'; - } + $this->loadFeatureValues([$idFeature], $idLang); + } - $tempFeatureValues = $this->database->executeS( - 'SELECT v.*, vl.*, ' . - 'IF(lifvlv.`url_name` IS NULL OR lifvlv.`url_name` = "", NULL, lifvlv.`url_name`) AS url_name, ' . - 'IF(lifvlv.`meta_title` IS NULL OR lifvlv.`meta_title` = "", NULL, lifvlv.`meta_title`) AS meta_title ' . - 'FROM `' . _DB_PREFIX_ . 'feature_value` v ' . - 'LEFT JOIN `' . _DB_PREFIX_ . 'feature_value_lang` vl ' . - 'ON (v.`id_feature_value` = vl.`id_feature_value` AND vl.`id_lang` = ' . (int) $idLang . ') ' . - 'LEFT JOIN `' . _DB_PREFIX_ . 'layered_indexable_feature_value_lang_value` lifvlv ' . - 'ON (v.`id_feature_value` = lifvlv.`id_feature_value` AND lifvlv.`id_lang` = ' . (int) $idLang . ') ' . - 'WHERE v.`id_feature` = ' . (int) $idFeature . ' ' . - $order - ); + return $this->featureValues[$idLang][$idFeature]; + } - foreach ($tempFeatureValues as $feature) { - $this->featureValues[$idLang][$idFeature][$feature['id_feature_value']] = $feature; + /** + * Loads in one query the values of the given features that are not loaded yet. + * + * @param int[] $idFeatures + * @param int $idLang + */ + public function loadFeatureValues(array $idFeatures, $idLang) + { + $idsToLoad = []; + foreach ($idFeatures as $idFeature) { + if (!isset($this->featureValues[$idLang][$idFeature])) { + $this->featureValues[$idLang][$idFeature] = []; + $idsToLoad[] = (int) $idFeature; } } + if (empty($idsToLoad)) { + return; + } - return $this->featureValues[$idLang][$idFeature]; + // feature_value.position only exists from PrestaShop 9.0 (PrestaShop/PrestaShop#37042), + // and this module still supports 8.2, where ordering by it is an unknown column. + if (self::isFeatureValuePositionSupported() + && (bool) Configuration::get('PS_LAYERED_FILTER_FEATURE_VALUES_USE_POSITION') + ) { + $order = 'ORDER BY v.`position` ASC'; + } else { + $order = 'ORDER BY vl.`value` ASC'; + } + + $tempFeatureValues = $this->database->executeS( + 'SELECT v.*, vl.*, ' . + 'IF(lifvlv.`url_name` IS NULL OR lifvlv.`url_name` = "", NULL, lifvlv.`url_name`) AS url_name, ' . + 'IF(lifvlv.`meta_title` IS NULL OR lifvlv.`meta_title` = "", NULL, lifvlv.`meta_title`) AS meta_title ' . + 'FROM `' . _DB_PREFIX_ . 'feature_value` v ' . + 'LEFT JOIN `' . _DB_PREFIX_ . 'feature_value_lang` vl ' . + 'ON (v.`id_feature_value` = vl.`id_feature_value` AND vl.`id_lang` = ' . (int) $idLang . ') ' . + 'LEFT JOIN `' . _DB_PREFIX_ . 'layered_indexable_feature_value_lang_value` lifvlv ' . + 'ON (v.`id_feature_value` = lifvlv.`id_feature_value` AND lifvlv.`id_lang` = ' . (int) $idLang . ') ' . + 'WHERE v.`id_feature` IN (' . implode(',', $idsToLoad) . ') ' . + $order + ); + + foreach ($tempFeatureValues as $feature) { + $this->featureValues[$idLang][$feature['id_feature']][$feature['id_feature_value']] = $feature; + } } } diff --git a/src/Product/Search.php b/src/Product/Search.php index faaefa534..bf63f6811 100644 --- a/src/Product/Search.php +++ b/src/Product/Search.php @@ -395,7 +395,10 @@ private function addControllerSpecificFilters() // If we want to display only products from this category AND not it's subcategories, // we add this one specific category ID, otherwise, we will add everything using nleft and nright if (Configuration::get('PS_LAYERED_FULL_TREE')) { + // Explicitly bound nleft on both sides to limit the index scan to the current category subtree. + // The upper bound is logically redundant but helps MySQL use a much smaller index range. $this->getSearchAdapter()->addFilter('nleft', [$category->nleft], '>='); + $this->getSearchAdapter()->addFilter('nleft', [$category->nright], '<='); $this->getSearchAdapter()->addFilter('nright', [$category->nright], '<='); } else { $this->addFilter('id_category', [$idCategory]); diff --git a/tests/php/FacetedSearch/Adapter/MySQLTest.php b/tests/php/FacetedSearch/Adapter/MySQLTest.php index b9cbc4deb..3a72279ca 100644 --- a/tests/php/FacetedSearch/Adapter/MySQLTest.php +++ b/tests/php/FacetedSearch/Adapter/MySQLTest.php @@ -373,7 +373,7 @@ public function testValueCountWithInitialPopulationAndStockManagement() $dbInstanceMock = Mockery::mock(Db::class); $dbInstanceMock->shouldReceive('executeS') ->once() - ->with('SELECT p.id_product, p.weight, COUNT(DISTINCT p.id_product) c FROM (SELECT p.id_product, p.weight FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute) WHERE ((sa.quantity>=0))) p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute) WHERE ((sa.quantity>=0)) GROUP BY p.weight') + ->with('SELECT p.id_product, p.weight, COUNT(DISTINCT p.id_product) c FROM (SELECT p.id_product, p.weight FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute) WHERE ((sa.quantity>=0))) p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute) WHERE ((sa.quantity>=0)) GROUP BY p.weight') ->andReturn( [ [ @@ -427,7 +427,7 @@ public function testGetQueryWithAllSelectField() ); $this->assertEquals( - 'SELECT p.id_product, pa.id_product_attribute, pac.id_attribute, a.id_attribute_group, fp.id_feature, ps.id_shop, p.id_feature_çvalue, cp.id_category, pl.name, c.nleft, c.nright, c.level_depth, sa.out_of_stock, SUM(sa.quantity) as quantity, psi.price_min, psi.price_max, psi.range_start, psi.range_end, cg.id_group, m.name FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) INNER JOIN ps_attribute a ON (a.id_attribute = pac.id_attribute) INNER JOIN ps_feature_product fp ON (p.id_product = fp.id_product) INNER JOIN ps_product_shop ps ON (p.id_product = ps.id_product AND ps.id_shop = 1 AND ps.active = TRUE) INNER JOIN ps_category_product cp ON (p.id_product = cp.id_product) INNER JOIN ps_product_lang pl ON (p.id_product = pl.id_product AND pl.id_shop = 1 AND pl.id_lang = 2) INNER JOIN ps_category c ON (cp.id_category = c.id_category AND c.active=1) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute) INNER JOIN ps_layered_price_index psi ON (psi.id_product = p.id_product AND psi.id_shop = 1 AND psi.id_currency = 4 AND psi.id_country = 3) LEFT JOIN ps_category_group cg ON (cg.id_category = c.id_category) LEFT JOIN ps_manufacturer m ON (p.id_manufacturer = m.id_manufacturer) ORDER BY p.id_product DESC', + 'SELECT p.id_product, pa.id_product_attribute, pac.id_attribute, a.id_attribute_group, fp.id_feature, ps.id_shop, p.id_feature_çvalue, cp.id_category, pl.name, c.nleft, c.nright, c.level_depth, sa.out_of_stock, SUM(sa.quantity) as quantity, psi.price_min, psi.price_max, psi.range_start, psi.range_end, cg.id_group, m.name FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) INNER JOIN ps_attribute a ON (a.id_attribute = pac.id_attribute) INNER JOIN ps_feature_product fp ON (p.id_product = fp.id_product) INNER JOIN ps_product_shop ps ON (p.id_product = ps.id_product AND ps.id_shop = 1 AND ps.active = TRUE) INNER JOIN ps_category_product cp ON (p.id_product = cp.id_product) INNER JOIN ps_product_lang pl ON (p.id_product = pl.id_product AND pl.id_shop = 1 AND pl.id_lang = 2) INNER JOIN ps_category c ON (cp.id_category = c.id_category AND c.active=1) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute) INNER JOIN ps_layered_price_index psi ON (psi.id_product = p.id_product AND psi.id_shop = 1 AND psi.id_currency = 4 AND psi.id_country = 3) LEFT JOIN ps_category_group cg ON (cg.id_category = c.id_category) LEFT JOIN ps_manufacturer m ON (p.id_manufacturer = m.id_manufacturer) ORDER BY p.id_product DESC', $this->adapter->getQuery() ); } @@ -453,7 +453,7 @@ public function testGetQueryWithManyFilters() $this->adapter->addFilter('id_product', [2, 20, 200], '='); $this->assertEquals( - 'SELECT p.id_product, sa.out_of_stock, SUM(sa.quantity) as quantity, psi.price_min, psi.price_max, psi.range_start, psi.range_end FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute) INNER JOIN ps_layered_price_index psi ON (psi.id_product = p.id_product AND psi.id_shop = 1 AND psi.id_currency = 4 AND psi.id_country = 3) WHERE p.condition IN (\'new\', \'used\') AND p.weight=\'10\' AND psi.price_min>=10 AND psi.price_min<=100 AND p.id_product IN (2, 20, 200) ORDER BY p.id_product DESC', + 'SELECT p.id_product, sa.out_of_stock, SUM(sa.quantity) as quantity, psi.price_min, psi.price_max, psi.range_start, psi.range_end FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute) INNER JOIN ps_layered_price_index psi ON (psi.id_product = p.id_product AND psi.id_shop = 1 AND psi.id_currency = 4 AND psi.id_country = 3) WHERE p.condition IN (\'new\', \'used\') AND p.weight=\'10\' AND psi.price_min>=10 AND psi.price_min<=100 AND p.id_product IN (2, 20, 200) ORDER BY p.id_product DESC', $this->adapter->getQuery() ); } @@ -586,7 +586,7 @@ public function testGetQueryWithComputeShowLastEnabled() $this->adapter->setOrderDirection('desc'); $this->assertEquals( - 'SELECT p.id_product, sa.out_of_stock FROM (SELECT p.id_product, cp.position, SUM(sa.quantity) as quantity FROM ps_product p INNER JOIN ps_category_product cp ON (p.id_product = cp.id_product) LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute)) p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute) ORDER BY IFNULL(p.quantity, 0) <= 0, IFNULL(p.quantity, 0) <= 0 AND FIELD(sa.out_of_stock, 0) ASC, p.position DESC, p.id_product DESC', + 'SELECT p.id_product, sa.out_of_stock FROM (SELECT p.id_product, cp.position, SUM(sa.quantity) as quantity FROM ps_product p INNER JOIN ps_category_product cp ON (p.id_product = cp.id_product) LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute)) p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute) ORDER BY IFNULL(p.quantity, 0) <= 0, IFNULL(p.quantity, 0) <= 0 AND FIELD(sa.out_of_stock, 0) ASC, p.position DESC, p.id_product DESC', $this->adapter->getQuery() ); } @@ -648,7 +648,7 @@ public function testGetQueryWithComputeShowLastEnabledAndDenyOrderOutOfStockProd $this->adapter->setOrderDirection('desc'); $this->assertEquals( - 'SELECT p.id_product, sa.out_of_stock FROM (SELECT p.id_product, cp.position, SUM(sa.quantity) as quantity FROM ps_product p INNER JOIN ps_category_product cp ON (p.id_product = cp.id_product) LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute)) p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute) ORDER BY IFNULL(p.quantity, 0) <= 0, IFNULL(p.quantity, 0) <= 0 AND FIELD(sa.out_of_stock, 1) DESC, p.position DESC, p.id_product DESC', + 'SELECT p.id_product, sa.out_of_stock FROM (SELECT p.id_product, cp.position, SUM(sa.quantity) as quantity FROM ps_product p INNER JOIN ps_category_product cp ON (p.id_product = cp.id_product) LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute)) p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute) ORDER BY IFNULL(p.quantity, 0) <= 0, IFNULL(p.quantity, 0) <= 0 AND FIELD(sa.out_of_stock, 1) DESC, p.position DESC, p.id_product DESC', $this->adapter->getQuery() ); } @@ -676,7 +676,7 @@ public function getManyOperationsFilters() ['out_of_stock', [1], '='], ], ], - 'expected' => 'SELECT p.id_product, sa.out_of_stock, SUM(sa.quantity) as quantity, psi.price_min, psi.price_max, psi.range_start, psi.range_end FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute) INNER JOIN ps_layered_price_index psi ON (psi.id_product = p.id_product AND psi.id_shop = 1 AND psi.id_currency = 4 AND psi.id_country = 3) LEFT JOIN ps_stock_available sa_1 ON (p.id_product = sa_1.id_product AND IFNULL(pac.id_product_attribute, 0) = sa_1.id_product_attribute) WHERE ((sa.quantity>=0 AND sa_1.out_of_stock IN (1, 3, 4)) OR (sa.quantity>0 AND sa_1.out_of_stock=1)) ORDER BY p.id_product DESC', + 'expected' => 'SELECT p.id_product, sa.out_of_stock, SUM(sa.quantity) as quantity, psi.price_min, psi.price_max, psi.range_start, psi.range_end FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute) INNER JOIN ps_layered_price_index psi ON (psi.id_product = p.id_product AND psi.id_shop = 1 AND psi.id_currency = 4 AND psi.id_country = 3) WHERE ((sa.quantity>=0 AND sa.out_of_stock IN (1, 3, 4)) OR (sa.quantity>0 AND sa.out_of_stock=1)) ORDER BY p.id_product DESC', ], [ 'fields' => [ @@ -693,7 +693,7 @@ public function getManyOperationsFilters() ['out_of_stock', [1], '='], ], ], - 'expected' => 'SELECT p.id_product, SUM(sa.quantity) as quantity FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute) LEFT JOIN ps_product_attribute_combination pac_1 ON (pa.id_product_attribute = pac_1.id_product_attribute) LEFT JOIN ps_stock_available sa_1 ON (p.id_product = sa_1.id_product AND IFNULL(pac.id_product_attribute, 0) = sa_1.id_product_attribute) WHERE ((pac.id_attribute=2 AND pac_1.id_attribute=4) OR (sa.quantity>0 AND sa_1.out_of_stock=1)) ORDER BY p.id_product DESC', + 'expected' => 'SELECT p.id_product, SUM(sa.quantity) as quantity FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_product_attribute_combination pac_1 ON (pa.id_product_attribute = pac_1.id_product_attribute) WHERE ((pac.id_attribute=2 AND pac_1.id_attribute=4) OR (sa.quantity>0 AND sa.out_of_stock=1)) ORDER BY p.id_product DESC', ], [ 'fields' => [ @@ -711,7 +711,7 @@ public function getManyOperationsFilters() ['out_of_stock', [0], '='], ], ], - 'expected' => 'SELECT p.id_product, SUM(sa.quantity) as quantity FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute) LEFT JOIN ps_product_attribute_combination pac_1 ON (pa.id_product_attribute = pac_1.id_product_attribute) LEFT JOIN ps_product_attribute_combination pac_2 ON (pa.id_product_attribute = pac_2.id_product_attribute) LEFT JOIN ps_stock_available sa_1 ON (p.id_product = sa_1.id_product AND IFNULL(pac.id_product_attribute, 0) = sa_1.id_product_attribute) WHERE ((pac.id_attribute=2 AND pac_1.id_attribute IN (4, 5, 6) AND pac_2.id_attribute IN (7, 8, 9)) OR (sa.quantity>0 AND sa_1.out_of_stock=0)) ORDER BY p.id_product DESC', + 'expected' => 'SELECT p.id_product, SUM(sa.quantity) as quantity FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_product_attribute_combination pac_1 ON (pa.id_product_attribute = pac_1.id_product_attribute) LEFT JOIN ps_product_attribute_combination pac_2 ON (pa.id_product_attribute = pac_2.id_product_attribute) WHERE ((pac.id_attribute=2 AND pac_1.id_attribute IN (4, 5, 6) AND pac_2.id_attribute IN (7, 8, 9)) OR (sa.quantity>0 AND sa.out_of_stock=0)) ORDER BY p.id_product DESC', ], ]; } @@ -732,8 +732,8 @@ public function oneSelectFieldDataProvider() ['nleft', 'SELECT c.nleft FROM ps_product p INNER JOIN ps_category_product cp ON (p.id_product = cp.id_product) INNER JOIN ps_category c ON (cp.id_category = c.id_category AND c.active=1) ORDER BY p.id_product DESC'], ['nright', 'SELECT c.nright FROM ps_product p INNER JOIN ps_category_product cp ON (p.id_product = cp.id_product) INNER JOIN ps_category c ON (cp.id_category = c.id_category AND c.active=1) ORDER BY p.id_product DESC'], ['level_depth', 'SELECT c.level_depth FROM ps_product p INNER JOIN ps_category_product cp ON (p.id_product = cp.id_product) INNER JOIN ps_category c ON (cp.id_category = c.id_category AND c.active=1) ORDER BY p.id_product DESC'], - ['out_of_stock', 'SELECT sa.out_of_stock FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute) ORDER BY p.id_product DESC'], - ['quantity', 'SELECT SUM(sa.quantity) as quantity FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute) ORDER BY p.id_product DESC'], + ['out_of_stock', 'SELECT sa.out_of_stock FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute) ORDER BY p.id_product DESC'], + ['quantity', 'SELECT SUM(sa.quantity) as quantity FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute) ORDER BY p.id_product DESC'], ['price_min', 'SELECT psi.price_min FROM ps_product p INNER JOIN ps_layered_price_index psi ON (psi.id_product = p.id_product AND psi.id_shop = 1 AND psi.id_currency = 4 AND psi.id_country = 3) ORDER BY p.id_product DESC'], ['price_max', 'SELECT psi.price_max FROM ps_product p INNER JOIN ps_layered_price_index psi ON (psi.id_product = p.id_product AND psi.id_shop = 1 AND psi.id_currency = 4 AND psi.id_country = 3) ORDER BY p.id_product DESC'], ['range_start', 'SELECT psi.range_start FROM ps_product p INNER JOIN ps_layered_price_index psi ON (psi.id_product = p.id_product AND psi.id_shop = 1 AND psi.id_currency = 4 AND psi.id_country = 3) ORDER BY p.id_product DESC'], diff --git a/tests/php/FacetedSearch/Filters/BlockTest.php b/tests/php/FacetedSearch/Filters/BlockTest.php index ef9e0812a..63fff2526 100644 --- a/tests/php/FacetedSearch/Filters/BlockTest.php +++ b/tests/php/FacetedSearch/Filters/BlockTest.php @@ -323,6 +323,8 @@ public function testGetFiltersBlockWithoutWeight() $adapterInitialMock = Mockery::mock(MySQL::class)->makePartial(); $adapterInitialMock->resetAll(); + $adapterInitialMock->addFilter('price_min', [43], '<'); + $adapterInitialMock->addFilter('price_max', [23], '>'); $adapterInitialMock->shouldReceive('getMinMaxValue') ->with('p.weight') ->andReturn([0, 0]); @@ -344,6 +346,8 @@ public function testGetFiltersBlockWithoutWeight() ] ) ); + $this->assertEquals(['<' => [[43]]], $adapterInitialMock->getFilter('price_min')); + $this->assertEquals(['>' => [[23]]], $adapterInitialMock->getFilter('price_max')); } public function testGetFiltersBlockWithQuantities() @@ -361,21 +365,21 @@ public function testGetFiltersBlockWithQuantities() $this->dbMock->shouldReceive('executeS') ->once() - ->with('SELECT COUNT(DISTINCT p.id_product) c FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute) LEFT JOIN ps_stock_available sa_1 ON (p.id_product = sa_1.id_product AND IFNULL(pac.id_product_attribute, 0) = sa_1.id_product_attribute) WHERE ((sa.quantity<=0 AND sa_1.out_of_stock=0))') + ->with('SELECT COUNT(DISTINCT p.id_product) c FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute) WHERE ((sa.quantity<=0 AND sa.out_of_stock=0))') ->andReturn([ ['c' => 1000], ]); $this->dbMock->shouldReceive('executeS') ->once() - ->with('SELECT COUNT(DISTINCT p.id_product) c FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute) WHERE ((sa.out_of_stock IN (1, 2)) OR (sa.quantity>0))') + ->with('SELECT COUNT(DISTINCT p.id_product) c FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute) WHERE ((sa.out_of_stock IN (1, 2)) OR (sa.quantity>0))') ->andReturn([ ['c' => 100], ]); $this->dbMock->shouldReceive('executeS') ->once() - ->with('SELECT COUNT(DISTINCT p.id_product) c FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_product_attribute_combination pac ON (pa.id_product_attribute = pac.id_product_attribute) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pac.id_product_attribute, 0) = sa.id_product_attribute) WHERE ((sa.quantity>0))') + ->with('SELECT COUNT(DISTINCT p.id_product) c FROM ps_product p LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product) LEFT JOIN ps_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.id_product_attribute, 0) = sa.id_product_attribute) WHERE ((sa.quantity>0))') ->andReturn([ ['c' => 50], ]); @@ -592,9 +596,7 @@ public function testGetFiltersBlockWithoutAttributes() $adapterInitialMock->resetAll(); $this->adapterMock->shouldReceive('getFilteredSearchAdapter') - ->once() - ->with('with_attributes_1') - ->andReturn($adapterInitialMock); + ->never(); $this->assertEquals( [ @@ -744,18 +746,22 @@ public function testGetFiltersBlockWithAttributesWithAttributesGroupsWithoutFeat [ [ 'id_attribute' => '1', + 'id_attribute_group' => '1', 'c' => '2', ], [ 'id_attribute' => '2', + 'id_attribute_group' => '1', 'c' => '2', ], [ 'id_attribute' => '3', + 'id_attribute_group' => '1', 'c' => '2', ], [ 'id_attribute' => '4', + 'id_attribute_group' => '1', 'c' => '2', ], ] @@ -817,9 +823,7 @@ public function testGetFiltersBlockWithoutFeatures() $adapterInitialMock = Mockery::mock(MySQL::class)->makePartial(); $adapterInitialMock->resetAll(); $this->adapterMock->shouldReceive('getFilteredSearchAdapter') - ->with('with_features_1') - ->once() - ->andReturn($adapterInitialMock); + ->never(); $this->assertEquals( [ @@ -840,11 +844,8 @@ public function testGetFiltersBlockWithoutFeaturesWithoutSearchFilter() $this->mockFeatures([]); $this->mockLayeredCategory([['type' => 'id_feature', 'id_value' => 1]]); - $adapterInitialMock = Mockery::mock(MySQL::class)->makePartial(); - $adapterInitialMock->resetAll(); $this->adapterMock->shouldReceive('getFilteredSearchAdapter') - ->once() - ->andReturn($adapterInitialMock); + ->never(); $this->assertEquals( [ @@ -862,7 +863,7 @@ public function testGetFiltersBlockWithoutFeaturesWithoutSearchFilter() public function testAndFeatureCountsKeepCurrentFeatureSelection() { // Keep the selected feature filter in the initial population for AND facet counts. - $this->mockFeatures([]); + $this->mockFeatures([['id_feature' => 1, 'name' => 'Feature']]); $this->mockLayeredCategory([[ 'type' => 'id_feature', 'id_value' => 1, @@ -871,6 +872,10 @@ public function testAndFeatureCountsKeepCurrentFeatureSelection() $filteredAdapter = Mockery::mock(MySQL::class)->makePartial(); $filteredAdapter->resetAll(); + $filteredAdapter->shouldReceive('valueCount') + ->with('id_feature_value') + ->once() + ->andReturn([]); $this->adapterMock->shouldReceive('getFilteredSearchAdapter') ->with() ->once() @@ -1164,7 +1169,7 @@ private function mockFeatureValues($idFeature, $featureValues = []) 'ON (v.`id_feature_value` = vl.`id_feature_value` AND vl.`id_lang` = 2) ' . 'LEFT JOIN `ps_layered_indexable_feature_value_lang_value` lifvlv ' . 'ON (v.`id_feature_value` = lifvlv.`id_feature_value` AND lifvlv.`id_lang` = 2) ' . - 'WHERE v.`id_feature` = ' . (int) $idFeature . ' ' . + 'WHERE v.`id_feature` IN (' . (int) $idFeature . ') ' . 'ORDER BY vl.`value` ASC' ) ->andReturn($featureValues); diff --git a/tests/php/FacetedSearch/Product/SearchTest.php b/tests/php/FacetedSearch/Product/SearchTest.php index 74f03101c..7a5877d7d 100644 --- a/tests/php/FacetedSearch/Product/SearchTest.php +++ b/tests/php/FacetedSearch/Product/SearchTest.php @@ -245,6 +245,11 @@ public function testInitSearchWithEmptyFiltersAndFullTree() 101, ], ], + '<=' => [ + [ + 102, + ], + ], ], 'nright' => [ '<=' => [