Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions src/Adapter/MySQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use Context;
use Db;
use Doctrine\Common\Collections\ArrayCollection;
use PrestaShop\Module\FacetedSearch\CombinationFeature;
use Product;
use StockAvailable;

Expand Down Expand Up @@ -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'];
}
}
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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);
Expand Down
88 changes: 88 additions & 0 deletions src/CombinationFeature.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Academic Free License version 3.0
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/AFL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @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;
}
}
24 changes: 24 additions & 0 deletions tests/php/FacetedSearch/Adapter/MySQLTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading