From 06b90cd92c1805db4e8af691c30b62055f66a864 Mon Sep 17 00:00:00 2001 From: BenjaminB Date: Thu, 8 Oct 2026 22:00:25 +0200 Subject: [PATCH 1/6] Bound the category subtree on nleft With only nleft >= and nright <= on the browsed category, the range read on the category index covers every following category, so MySQL computed the population from all the shop products and filtered the subtree last. Bounding nleft by the category nright is redundant in the nested set but makes the index range match the subtree. --- src/Filters/Block.php | 1 + src/Product/Search.php | 1 + tests/php/FacetedSearch/Product/SearchTest.php | 5 +++++ 3 files changed, 7 insertions(+) diff --git a/src/Filters/Block.php b/src/Filters/Block.php index 1afa815e4..5e5d5ff8c 100644 --- a/src/Filters/Block.php +++ b/src/Filters/Block.php @@ -959,6 +959,7 @@ private function addCategoriesBlockFilters(InterfaceAdapter $filteredSearchAdapt } $filteredSearchAdapter->addFilter('nleft', [$parent->nleft], '>'); + $filteredSearchAdapter->addFilter('nleft', [$parent->nright], '<'); $filteredSearchAdapter->addFilter('nright', [$parent->nright], '<'); } diff --git a/src/Product/Search.php b/src/Product/Search.php index faaefa534..a608ed75a 100644 --- a/src/Product/Search.php +++ b/src/Product/Search.php @@ -396,6 +396,7 @@ private function addControllerSpecificFilters() // we add this one specific category ID, otherwise, we will add everything using nleft and nright if (Configuration::get('PS_LAYERED_FULL_TREE')) { $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/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' => [ '<=' => [ From c44cadbef14ececca6536ad55644cdd0fca9fcfb Mon Sep 17 00:00:00 2001 From: BenjaminB Date: Thu, 8 Oct 2026 22:00:58 +0200 Subject: [PATCH 2/6] Join the stock on the combination The stock_available join went through product_attribute_combination, which repeated each combination stock row once per attribute. Joining it on product_attribute selects the same rows without the duplicates. The quantity summed to order the out of stock products last no longer counts a combination once per attribute: it is now the actual stock of the product. --- src/Adapter/MySQL.php | 8 ++++---- tests/php/FacetedSearch/Adapter/MySQLTest.php | 20 +++++++++---------- tests/php/FacetedSearch/Filters/BlockTest.php | 6 +++--- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/Adapter/MySQL.php b/src/Adapter/MySQL.php index 8f2dc279d..d17177cdb 100644 --- a/src/Adapter/MySQL.php +++ b/src/Adapter/MySQL.php @@ -310,18 +310,18 @@ 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', ], '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', 'aggregateFunction' => 'SUM', 'aggregateFieldName' => 'quantity', ], diff --git a/tests/php/FacetedSearch/Adapter/MySQLTest.php b/tests/php/FacetedSearch/Adapter/MySQLTest.php index b9cbc4deb..adaec1eca 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) LEFT JOIN ps_stock_available sa_1 ON (p.id_product = sa_1.id_product AND IFNULL(pa.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', ], [ '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) LEFT JOIN ps_stock_available sa_1 ON (p.id_product = sa_1.id_product AND IFNULL(pa.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', ], [ '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) LEFT JOIN ps_stock_available sa_1 ON (p.id_product = sa_1.id_product AND IFNULL(pa.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', ], ]; } @@ -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..a586dccd7 100644 --- a/tests/php/FacetedSearch/Filters/BlockTest.php +++ b/tests/php/FacetedSearch/Filters/BlockTest.php @@ -361,21 +361,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) LEFT JOIN ps_stock_available sa_1 ON (p.id_product = sa_1.id_product AND IFNULL(pa.id_product_attribute, 0) = sa_1.id_product_attribute) WHERE ((sa.quantity<=0 AND sa_1.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], ]); From 296d4909990c8a4a46c0ea3b17bc24cb344b4bc9 Mon Sep 17 00:00:00 2001 From: BenjaminB Date: Thu, 8 Oct 2026 22:01:13 +0200 Subject: [PATCH 3/6] Count unselected attribute groups and features in one query Each attribute group and each feature facet ran its own count query, and each one recomputed the population of the category. The facets without selected value, and the AND features, share the current filters: their values are now counted in one query per facet type. A facet with a selected value keeps its own query, as it ignores its own selection. The values of the displayed features are also loaded in one query. --- src/Filters/Block.php | 136 +++++++++++------- src/Filters/DataAccessor.php | 73 ++++++---- tests/php/FacetedSearch/Filters/BlockTest.php | 25 ++-- 3 files changed, 147 insertions(+), 87 deletions(-) diff --git a/src/Filters/Block.php b/src/Filters/Block.php index 5e5d5ff8c..d5fda5e9c 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); @@ -697,28 +700,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 +716,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 +799,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 +865,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 * 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/tests/php/FacetedSearch/Filters/BlockTest.php b/tests/php/FacetedSearch/Filters/BlockTest.php index a586dccd7..18d8ed7e4 100644 --- a/tests/php/FacetedSearch/Filters/BlockTest.php +++ b/tests/php/FacetedSearch/Filters/BlockTest.php @@ -592,9 +592,7 @@ public function testGetFiltersBlockWithoutAttributes() $adapterInitialMock->resetAll(); $this->adapterMock->shouldReceive('getFilteredSearchAdapter') - ->once() - ->with('with_attributes_1') - ->andReturn($adapterInitialMock); + ->never(); $this->assertEquals( [ @@ -744,18 +742,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 +819,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 +840,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 +859,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 +868,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 +1165,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); From f49a1cce6bf0b4327196d7684847b28b9159a786 Mon Sep 17 00:00:00 2001 From: BenjaminB Date: Thu, 8 Oct 2026 22:01:25 +0200 Subject: [PATCH 4/6] Share the stock join of an operation The "Not available" count filters on quantity and out_of_stock in the same operation, and each item got its own stock_available join on the same row. The stock fields are now flagged to share the join of their operation: the unique key of stock_available guarantees a single row. Operations on other tables keep one join per item, needed when they require several values. --- src/Adapter/MySQL.php | 33 ++++++++++++------- tests/php/FacetedSearch/Adapter/MySQLTest.php | 6 ++-- tests/php/FacetedSearch/Filters/BlockTest.php | 2 +- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/Adapter/MySQL.php b/src/Adapter/MySQL.php index d17177cdb..79989a2bd 100644 --- a/src/Adapter/MySQL.php +++ b/src/Adapter/MySQL.php @@ -314,6 +314,7 @@ protected function getFieldMapping() $stockCondition . ')', 'joinType' => self::LEFT_JOIN, 'dependencyField' => 'id_product_attribute', + 'shareOperationJoin' => true, ], 'quantity' => [ 'tableName' => 'stock_available', @@ -322,6 +323,7 @@ protected function getFieldMapping() $stockCondition . ')', 'joinType' => self::LEFT_JOIN, '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/tests/php/FacetedSearch/Adapter/MySQLTest.php b/tests/php/FacetedSearch/Adapter/MySQLTest.php index adaec1eca..3a72279ca 100644 --- a/tests/php/FacetedSearch/Adapter/MySQLTest.php +++ b/tests/php/FacetedSearch/Adapter/MySQLTest.php @@ -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_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_stock_available sa_1 ON (p.id_product = sa_1.id_product AND IFNULL(pa.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_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_stock_available sa_1 ON (p.id_product = sa_1.id_product AND IFNULL(pa.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_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) LEFT JOIN ps_stock_available sa_1 ON (p.id_product = sa_1.id_product AND IFNULL(pa.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', ], ]; } diff --git a/tests/php/FacetedSearch/Filters/BlockTest.php b/tests/php/FacetedSearch/Filters/BlockTest.php index 18d8ed7e4..a3f67280d 100644 --- a/tests/php/FacetedSearch/Filters/BlockTest.php +++ b/tests/php/FacetedSearch/Filters/BlockTest.php @@ -361,7 +361,7 @@ 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_stock_available sa ON (p.id_product = sa.id_product AND IFNULL(pa.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(pa.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], ]); From 04f7e9f05041e4d27e30673f81a90186cf53ebfc Mon Sep 17 00:00:00 2001 From: BenjaminB Date: Fri, 9 Oct 2026 22:23:42 +0200 Subject: [PATCH 5/6] Restore the price filter after an empty weight block --- src/Filters/Block.php | 13 +++++++------ tests/php/FacetedSearch/Filters/BlockTest.php | 4 ++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Filters/Block.php b/src/Filters/Block.php index d5fda5e9c..53d7d6f77 100644 --- a/src/Filters/Block.php +++ b/src/Filters/Block.php @@ -336,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(), @@ -350,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; } diff --git a/tests/php/FacetedSearch/Filters/BlockTest.php b/tests/php/FacetedSearch/Filters/BlockTest.php index a3f67280d..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() From 988e00dd082f44b457a84c3d0869f1cfa13c9967 Mon Sep 17 00:00:00 2001 From: BenjaminB Date: Sat, 24 Oct 2026 11:42:09 +0200 Subject: [PATCH 6/6] Explain the nleft upper bound on the category filter --- src/Filters/Block.php | 1 + src/Product/Search.php | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/Filters/Block.php b/src/Filters/Block.php index 53d7d6f77..cdc45b2b7 100644 --- a/src/Filters/Block.php +++ b/src/Filters/Block.php @@ -999,6 +999,7 @@ 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/Product/Search.php b/src/Product/Search.php index a608ed75a..bf63f6811 100644 --- a/src/Product/Search.php +++ b/src/Product/Search.php @@ -395,6 +395,8 @@ 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], '<=');