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
69 changes: 60 additions & 9 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,37 @@ 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;
$featureJoinCondition = '(p.id_product = fp.id_product)';
$featureJoinExtra = [];
if ($this->isCombinationFeatureFilteringEnabled()) {
// Derived table (id_product, id_product_attribute, id_feature, id_feature_value) merging
// product-level feature values with the ones defined at combination level
// (feature_product_attribute, resolved to their product through product_attribute). The
// id_product_attribute column is NULL for product-level values, so a feature filter can be
// correlated with a specific combination. The UNION removes duplicates so a value defined
// at both levels is not counted twice.
$featureProductTable = '(SELECT id_product, NULL AS id_product_attribute, id_feature, id_feature_value'
. ' FROM ' . _DB_PREFIX_ . 'feature_product'
. ' UNION'
. ' SELECT pa.id_product, pa.id_product_attribute, 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)';
$featureProductRawTable = true;
// Correlate the feature row with the combination currently joined (pa) so that a feature
// filter and an attribute filter must be satisfied by the same combination, not by two
// different ones. Product-level feature values (id_product_attribute IS NULL) keep
// applying to every combination.
$featureJoinCondition = '(p.id_product = fp.id_product'
. ' AND (fp.id_product_attribute IS NULL OR fp.id_product_attribute = pa.id_product_attribute))';
$featureJoinExtra = ['dependencyField' => 'id_product_attribute'];
}

$filterToTableMapping = [
'id_product_attribute' => [
'tableName' => 'product_attribute',
Expand All @@ -182,12 +219,13 @@ protected function getFieldMapping()
'joinType' => self::INNER_JOIN,
'dependencyField' => 'id_attribute',
],
'id_feature' => [
'tableName' => 'feature_product',
'id_feature' => array_merge([
'tableName' => $featureProductTable,
'tableAlias' => 'fp',
'joinCondition' => '(p.id_product = fp.id_product)',
'joinCondition' => $featureJoinCondition,
'joinType' => self::INNER_JOIN,
],
'rawTable' => $featureProductRawTable,
], $featureJoinExtra),
'id_shop' => [
'tableName' => 'product_shop',
'tableAlias' => 'ps',
Expand All @@ -202,12 +240,13 @@ protected function getFieldMapping()
$this->getContext()->shop->id . ' AND ps.active = TRUE)',
'joinType' => self::INNER_JOIN,
],
'id_feature_value' => [
'tableName' => 'feature_product',
'id_feature_value' => array_merge([
'tableName' => $featureProductTable,
'tableAlias' => 'fp',
'joinCondition' => '(p.id_product = fp.id_product)',
'joinCondition' => $featureJoinCondition,
'joinType' => self::LEFT_JOIN,
],
'rawTable' => $featureProductRawTable,
], $featureJoinExtra),
'id_category' => [
'tableName' => 'category_product',
'tableAlias' => 'cp',
Expand Down Expand Up @@ -339,6 +378,17 @@ 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();
}

/**
* Get the joined and escaped value from an multi-dimensional array
*
Expand Down Expand Up @@ -723,6 +773,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;
}
}
63 changes: 63 additions & 0 deletions tests/php/FacetedSearch/Adapter/MySQLTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,69 @@ 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. Each row also exposes the
// combination it belongs to (id_product_attribute, NULL at product level) and the feature
// join is correlated with the product_attribute (pa) join.
$this->assertEquals(
'SELECT fp.id_feature FROM ps_product p'
. ' LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product)'
. ' INNER JOIN (SELECT id_product, NULL AS id_product_attribute, id_feature, id_feature_value'
. ' FROM ps_feature_product'
. ' UNION SELECT pa.id_product, pa.id_product_attribute, 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'
. ' AND (fp.id_product_attribute IS NULL OR fp.id_product_attribute = pa.id_product_attribute))'
. ' ORDER BY p.id_product DESC',
$adapter->getQuery()
);
}

public function testFeatureAndAttributeFiltersMustMatchTheSameCombination()
{
$adapter = new class() extends MySQL {
protected function isCombinationFeatureFilteringEnabled()
{
return true;
}
};

// Filter on a feature value carried by one combination and on an attribute carried by
// another one, the way Product\Search adds them.
$adapter->addOperationsFilter('with_features_3', [[['id_feature_value', [11]]]]);
$adapter->addOperationsFilter('with_attributes_1', [[['id_attribute', [7]]]]);

// Both the feature (fp) and the attribute (pac) joins are correlated with the same
// product_attribute (pa) row, so the two filters must be satisfied by the same combination.
$this->assertEquals(
'SELECT FROM ps_product p'
. ' LEFT JOIN ps_product_attribute pa ON (p.id_product = pa.id_product)'
. ' LEFT JOIN (SELECT id_product, NULL AS id_product_attribute, id_feature, id_feature_value'
. ' FROM ps_feature_product'
. ' UNION SELECT pa.id_product, pa.id_product_attribute, 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'
. ' AND (fp.id_product_attribute IS NULL OR fp.id_product_attribute = pa.id_product_attribute))'
. ' LEFT JOIN ps_product_attribute_combination pac_1 ON (pa.id_product_attribute = pac_1.id_product_attribute)'
. ' WHERE ((fp.id_feature_value=11)) AND ((pac_1.id_attribute=7))'
. ' ORDER BY p.id_product DESC',
$adapter->getQuery()
);
}

public function testGetMinMaxPriceValue()
{
$dbInstanceMock = Mockery::mock(Db::class)->makePartial();
Expand Down
Loading