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..dea58dc41 --- /dev/null +++ b/src/CombinationFeature.php @@ -0,0 +1,88 @@ + + * @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 { + /** @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); + } 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();