From c53ac744695528faf059f9c2f95cddfcdf8419d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Viguier?= Date: Mon, 17 Aug 2026 11:51:22 +0200 Subject: [PATCH 1/2] Support combination feature values in feature filters Feature filters now also take combination (product_attribute) feature values into account, so a feature carried only by combinations appears in the facets with correct product counts. Gated by PrestaShop >= 9.3 and the combination_feature_values feature flag. --- src/Adapter/MySQL.php | 59 ++++++++++++- src/CombinationFeature.php | 87 +++++++++++++++++++ tests/php/FacetedSearch/Adapter/MySQLTest.php | 24 +++++ 3 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 src/CombinationFeature.php diff --git a/src/Adapter/MySQL.php b/src/Adapter/MySQL.php index 8af448ce0..056cdb915 100644 --- a/src/Adapter/MySQL.php +++ b/src/Adapter/MySQL.php @@ -24,6 +24,7 @@ use Context; use Db; use Doctrine\Common\Collections\ArrayCollection; +use PrestaShop\Module\FacetedSearch\CombinationFeature; use Product; use StockAvailable; @@ -117,7 +118,12 @@ public function getQuery() // Add join conditions if any foreach ($joinConditions as $joinAliasInfos) { foreach ($joinAliasInfos as $tableAlias => $joinInfos) { - $query .= ' ' . $joinInfos['joinType'] . ' ' . _DB_PREFIX_ . $joinInfos['tableName'] . ' ' . + // A "raw" table is already a full table expression (e.g. a derived table) and must not + // be prefixed, otherwise it is a regular table name living behind the database prefix. + $tableName = !empty($joinInfos['rawTable']) + ? $joinInfos['tableName'] + : _DB_PREFIX_ . $joinInfos['tableName']; + $query .= ' ' . $joinInfos['joinType'] . ' ' . $tableName . ' ' . $tableAlias . ' ON ' . $joinInfos['joinCondition']; } } @@ -161,6 +167,17 @@ protected function getFieldMapping() 'sa' ); + // Feature filters are resolved against the feature_product table by default. When combination + // feature values are enabled (PrestaShop >= 9.3 + feature flag), we swap that table for a + // derived table that also exposes the feature values defined at combination level, so a product + // becomes filterable by a feature value carried by any of its combinations. + $featureProductTable = 'feature_product'; + $featureProductRawTable = false; + if ($this->isCombinationFeatureFilteringEnabled()) { + $featureProductTable = $this->getFeatureProductDerivedTable(); + $featureProductRawTable = true; + } + $filterToTableMapping = [ 'id_product_attribute' => [ 'tableName' => 'product_attribute', @@ -183,10 +200,11 @@ protected function getFieldMapping() 'dependencyField' => 'id_attribute', ], 'id_feature' => [ - 'tableName' => 'feature_product', + 'tableName' => $featureProductTable, 'tableAlias' => 'fp', 'joinCondition' => '(p.id_product = fp.id_product)', 'joinType' => self::INNER_JOIN, + 'rawTable' => $featureProductRawTable, ], 'id_shop' => [ 'tableName' => 'product_shop', @@ -203,10 +221,11 @@ protected function getFieldMapping() 'joinType' => self::INNER_JOIN, ], 'id_feature_value' => [ - 'tableName' => 'feature_product', + 'tableName' => $featureProductTable, 'tableAlias' => 'fp', 'joinCondition' => '(p.id_product = fp.id_product)', 'joinType' => self::LEFT_JOIN, + 'rawTable' => $featureProductRawTable, ], 'id_category' => [ 'tableName' => 'category_product', @@ -339,6 +358,39 @@ protected function getFieldMapping() return $filterToTableMapping; } + /** + * Whether feature filters must also take combination feature values into account. + * Extracted so it can be overridden in tests. + * + * @return bool + */ + protected function isCombinationFeatureFilteringEnabled() + { + return CombinationFeature::isFilteringEnabled(); + } + + /** + * Builds a derived table, shaped exactly like feature_product (id_product, id_feature, + * id_feature_value), that merges the product-level feature values with the ones defined at + * combination level (feature_product_attribute, resolved to their product through + * product_attribute). The UNION removes duplicates so a value defined at both levels is not + * counted twice. + * + * Overrides must keep the exact same columns (id_product, id_feature, id_feature_value) so the + * derived table stays a drop-in replacement for feature_product in getFieldMapping(). + * + * @return string + */ + protected function getFeatureProductDerivedTable() + { + return '(SELECT id_product, id_feature, id_feature_value' + . ' FROM ' . _DB_PREFIX_ . 'feature_product' + . ' UNION' + . ' SELECT pa.id_product, fpa.id_feature, fpa.id_feature_value' + . ' FROM ' . _DB_PREFIX_ . 'feature_product_attribute fpa' + . ' INNER JOIN ' . _DB_PREFIX_ . 'product_attribute pa ON pa.id_product_attribute = fpa.id_product_attribute)'; + } + /** * Get the joined and escaped value from an multi-dimensional array * @@ -723,6 +775,7 @@ private function addJoinConditions(ArrayCollection $joinList, array $joinMapping 'tableName' => $joinMapping['tableName'], 'joinCondition' => $joinMapping['joinCondition'], 'joinType' => $joinMapping['joinType'], + 'rawTable' => !empty($joinMapping['rawTable']), ]; $joinList->set($joinMapping['tableAlias'] . '_' . $joinMapping['tableName'], $joinInfos); diff --git a/src/CombinationFeature.php b/src/CombinationFeature.php new file mode 100644 index 000000000..1fc302e63 --- /dev/null +++ b/src/CombinationFeature.php @@ -0,0 +1,87 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\FacetedSearch; + +use Context; +use PrestaShop\PrestaShop\Adapter\ContainerFinder; +use Throwable; + +/** + * Tells whether the faceted search must also take combination (product_attribute) feature values + * into account, in addition to the product ones. + * + * This is only available from PrestaShop 9.3 (the version that introduced feature values at + * combination level) and must additionally be turned on through the "combination_feature_values" + * feature flag. + */ +class CombinationFeature +{ + /** + * Name of the core feature flag guarding combination feature values. + */ + public const FEATURE_FLAG = 'combination_feature_values'; + + /** + * Minimum PrestaShop version exposing combination feature values. + */ + public const MIN_PS_VERSION = '9.3.0'; + + /** + * @var bool|null + */ + private static $enabled; + + /** + * @return bool + */ + public static function isFilteringEnabled() + { + if (self::$enabled !== null) { + return self::$enabled; + } + + self::$enabled = false; + + // Combination feature values simply do not exist before PrestaShop 9.3. + if (version_compare(_PS_VERSION_, self::MIN_PS_VERSION, '<')) { + return self::$enabled; + } + + try { + $container = (new ContainerFinder(Context::getContext()))->getContainer(); + $checker = $container->get('PrestaShop\\PrestaShop\\Core\\FeatureFlag\\FeatureFlagStateCheckerInterface'); + self::$enabled = $checker !== null && $checker->isEnabled(self::FEATURE_FLAG); + } catch (Throwable $e) { + // If the container or the checker is not reachable, stay on the historical behavior. + self::$enabled = false; + } + + return self::$enabled; + } + + /** + * Resets the memoized state, mostly useful for tests. + */ + public static function resetCache() + { + self::$enabled = null; + } +} diff --git a/tests/php/FacetedSearch/Adapter/MySQLTest.php b/tests/php/FacetedSearch/Adapter/MySQLTest.php index 716c160f6..b4285d3d9 100644 --- a/tests/php/FacetedSearch/Adapter/MySQLTest.php +++ b/tests/php/FacetedSearch/Adapter/MySQLTest.php @@ -91,6 +91,30 @@ public function testGetQueryWithOneSelectField($type, $expected) ); } + public function testGetQueryWithFeatureIncludesCombinationFeatures() + { + $adapter = new class() extends MySQL { + protected function isCombinationFeatureFilteringEnabled() + { + return true; + } + }; + + $adapter->addSelectField('id_feature'); + + // The feature_product table is replaced by a derived table merging product-level and + // combination-level (feature_product_attribute) feature values. + $this->assertEquals( + 'SELECT fp.id_feature FROM ps_product p' + . ' INNER JOIN (SELECT id_product, id_feature, id_feature_value FROM ps_feature_product' + . ' UNION SELECT pa.id_product, fpa.id_feature, fpa.id_feature_value' + . ' FROM ps_feature_product_attribute fpa' + . ' INNER JOIN ps_product_attribute pa ON pa.id_product_attribute = fpa.id_product_attribute) fp' + . ' ON (p.id_product = fp.id_product) ORDER BY p.id_product DESC', + $adapter->getQuery() + ); + } + public function testGetMinMaxPriceValue() { $dbInstanceMock = Mockery::mock(Db::class)->makePartial(); From cd8456836ec2364f321d16e165cfe8ab04b66dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Viguier?= Date: Mon, 17 Aug 2026 12:18:07 +0200 Subject: [PATCH 2/2] Fix PHPStan on the container lookup Narrow the container to Psr\Container\ContainerInterface so PHPStan resolves get() through an interface available on every PrestaShop version, instead of the ContainerBuilder branch that is not resolvable under the 9.0.3 analysis target. --- src/CombinationFeature.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CombinationFeature.php b/src/CombinationFeature.php index 1fc302e63..dea58dc41 100644 --- a/src/CombinationFeature.php +++ b/src/CombinationFeature.php @@ -66,6 +66,7 @@ public static function isFilteringEnabled() } try { + /** @var \Psr\Container\ContainerInterface $container */ $container = (new ContainerFinder(Context::getContext()))->getContainer(); $checker = $container->get('PrestaShop\\PrestaShop\\Core\\FeatureFlag\\FeatureFlagStateCheckerInterface'); self::$enabled = $checker !== null && $checker->isEnabled(self::FEATURE_FLAG);