diff --git a/src/module-elasticsuite-thesaurus/Config/ThesaurusCacheConfig.php b/src/module-elasticsuite-thesaurus/Config/ThesaurusCacheConfig.php new file mode 100644 index 000000000..4df0d91d9 --- /dev/null +++ b/src/module-elasticsuite-thesaurus/Config/ThesaurusCacheConfig.php @@ -0,0 +1,78 @@ + + * @copyright 2024 Smile + * @license Open Software License ("OSL") v. 3.0 + */ + +namespace Smile\ElasticsuiteThesaurus\Config; + +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Store\Model\ScopeInterface; +use Smile\ElasticsuiteCore\Api\Search\Request\ContainerConfigurationInterface; + +/** + * Thesaurus cache configuration helper. + * + * @category Smile + * @package Smile\ElasticsuiteThesaurus + * @author Richard Bayet + */ +class ThesaurusCacheConfig +{ + /** @var string */ + const ALWAYS_CACHE_RESULTS_XML_PATH = 'smile_elasticsuite_thesaurus_settings/cache/always'; + + /** @var string */ + const MIN_REWRITES_FOR_CACHING_XML_PATH = 'smile_elasticsuite_thesaurus_settings/cache/min_rewites'; + + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * Constructor. + * + * @param ScopeConfigInterface $scopeConfig Scope config interface. + */ + public function __construct(ScopeConfigInterface $scopeConfig) + { + $this->scopeConfig = $scopeConfig; + } + + /** + * Returns true if it is allowed by the config to store in cache the results of the thesaurus rules computation. + * + * @param ContainerConfigurationInterface $config Container configuration. + * @param int $rewritesCount Number of rewrites/alternative queries. + * + * @return bool + */ + public function isCacheStorageAllowed(ContainerConfigurationInterface $config, $rewritesCount) + { + $alwaysCache = $this->scopeConfig->isSetFlag( + self::ALWAYS_CACHE_RESULTS_XML_PATH, + ScopeInterface::SCOPE_STORES, + $config->getStoreId() + ); + + if (false === $alwaysCache) { + $minRewritesForCaching = $this->scopeConfig->getValue( + self::MIN_REWRITES_FOR_CACHING_XML_PATH, + ScopeInterface::SCOPE_STORES, + $config->getStoreId() + ); + + return ($rewritesCount >= $minRewritesForCaching); + } + + return true; + } +} diff --git a/src/module-elasticsuite-thesaurus/Model/Index.php b/src/module-elasticsuite-thesaurus/Model/Index.php index 028842063..78b79fefd 100644 --- a/src/module-elasticsuite-thesaurus/Model/Index.php +++ b/src/module-elasticsuite-thesaurus/Model/Index.php @@ -19,6 +19,7 @@ use Smile\ElasticsuiteCore\Api\Search\Request\ContainerConfigurationInterface; use Smile\ElasticsuiteThesaurus\Config\ThesaurusConfigFactory; use Smile\ElasticsuiteThesaurus\Config\ThesaurusConfig; +use Smile\ElasticsuiteThesaurus\Config\ThesaurusCacheConfig; use Smile\ElasticsuiteThesaurus\Api\Data\ThesaurusInterface; use Smile\ElasticsuiteCore\Helper\Cache as CacheHelper; @@ -66,6 +67,11 @@ class Index */ private $cacheHelper; + /** + * @var ThesaurusConfig + */ + private $thesaurusCacheConfig; + /** * Constructor. * @@ -73,17 +79,20 @@ class Index * @param IndexSettingsHelper $indexSettingsHelper Index Settings Helper. * @param CacheHelper $cacheHelper ES caching helper. * @param ThesaurusConfigFactory $thesaurusConfigFactory Thesaurus configuration factory. + * @param ThesaurusCacheConfig $thesaurusCacheConfig Thesaurus cache configuration helper. */ public function __construct( ClientInterface $client, IndexSettingsHelper $indexSettingsHelper, CacheHelper $cacheHelper, - ThesaurusConfigFactory $thesaurusConfigFactory + ThesaurusConfigFactory $thesaurusConfigFactory, + ThesaurusCacheConfig $thesaurusCacheConfig ) { $this->client = $client; $this->indexSettingsHelper = $indexSettingsHelper; $this->thesaurusConfigFactory = $thesaurusConfigFactory; $this->cacheHelper = $cacheHelper; + $this->thesaurusCacheConfig = $thesaurusCacheConfig; } /** @@ -104,7 +113,9 @@ public function getQueryRewrites(ContainerConfigurationInterface $containerConfi if ($queryRewrites === false) { $queryRewrites = $this->computeQueryRewrites($containerConfig, $queryText, $originalBoost); - $this->cacheHelper->saveCache($cacheKey, $queryRewrites, $cacheTags); + if ($this->thesaurusCacheConfig->isCacheStorageAllowed($containerConfig, count($queryRewrites))) { + $this->cacheHelper->saveCache($cacheKey, $queryRewrites, $cacheTags); + } } return $queryRewrites; diff --git a/src/module-elasticsuite-thesaurus/Test/Unit/Config/ThesaurusCacheConfigTest.php b/src/module-elasticsuite-thesaurus/Test/Unit/Config/ThesaurusCacheConfigTest.php new file mode 100644 index 000000000..e3ecca7d3 --- /dev/null +++ b/src/module-elasticsuite-thesaurus/Test/Unit/Config/ThesaurusCacheConfigTest.php @@ -0,0 +1,131 @@ + + * @copyright 2024 Smile + * @license Open Software License ("OSL") v. 3.0 + */ + +declare(strict_types = 1); + +namespace Smile\ElasticsuiteThesaurus\Test\Unit\Config; + +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Store\Model\ScopeInterface; +use PHPUnit\Framework\MockObject\MockObject; +use Smile\ElasticsuiteCore\Api\Search\Request\ContainerConfigurationInterface; +use Smile\ElasticsuiteThesaurus\Config\ThesaurusCacheConfig; + +/** + * Thesaurus Cache Config helper unit tests. + * + * @category Smile + * @package Smile\ElasticsuiteThesaurus + * @author Richard BAYET + */ +class ThesaurusCacheConfigTest extends \PHPUnit\Framework\TestCase +{ + /** + * Test the cache storage limitation behavior. + * @dataProvider cacheStorageLimitationDataProvider + * + * @param array $isSetFlagReturnsMap Map of return results for method 'isSetFlag'. + * @param array $getValueReturnsMap Map of return results for method 'getValue'. + * @param int $storeId Store Id. + * @param int $rewritesCount Number of rewrites/alternative queries. + * @param bool $expectedCacheStorageAllowed Expected cache storage allowed result. + */ + public function testCacheStorageLimitation( + $isSetFlagReturnsMap, + $getValueReturnsMap, + $storeId, + $rewritesCount, + $expectedCacheStorageAllowed + ) { + $containerConfigMock = $this->getContainerConfigurationInterfaceMock(); + $containerConfigMock->method('getStoreId')->willReturn($storeId); + + $scopeConfigMock = $this->getScopeConfigInterfaceMock(); + $scopeConfigMock->method('isSetFlag')->willReturnMap($isSetFlagReturnsMap); + $scopeConfigMock->method('getValue')->willReturnMap($getValueReturnsMap); + + $thesaurusCacheConfig = new ThesaurusCacheConfig($scopeConfigMock); + $this->assertEquals( + $expectedCacheStorageAllowed, + $thesaurusCacheConfig->isCacheStorageAllowed($containerConfigMock, $rewritesCount) + ); + } + + /** + * Data provider for testCacheStorageLimitation. + * + * @return array + */ + public function cacheStorageLimitationDataProvider() + { + $isSetFlagReturnsMap = [ + [ThesaurusCacheConfig::ALWAYS_CACHE_RESULTS_XML_PATH, ScopeInterface::SCOPE_STORES, 1, true], + [ThesaurusCacheConfig::ALWAYS_CACHE_RESULTS_XML_PATH, ScopeInterface::SCOPE_STORES, 2, false], + [ThesaurusCacheConfig::ALWAYS_CACHE_RESULTS_XML_PATH, ScopeInterface::SCOPE_STORES, 3, false], + ]; + $getValueReturnsMap = [ + [ThesaurusCacheConfig::MIN_REWRITES_FOR_CACHING_XML_PATH, ScopeInterface::SCOPE_STORES, 1, 10], + [ThesaurusCacheConfig::MIN_REWRITES_FOR_CACHING_XML_PATH, ScopeInterface::SCOPE_STORES, 2, 0], + [ThesaurusCacheConfig::MIN_REWRITES_FOR_CACHING_XML_PATH, ScopeInterface::SCOPE_STORES, 3, 10], + ]; + + return [ + /* + * [isSetFlagReturnsMap, getValueReturnsMap, storeId, rewritesCount, expectedCacheStorageAllowed] + */ + // StoreId 1. + [$isSetFlagReturnsMap, $getValueReturnsMap, 1, 0, true], + [$isSetFlagReturnsMap, $getValueReturnsMap, 1, 9, true], + [$isSetFlagReturnsMap, $getValueReturnsMap, 1, 10, true], + [$isSetFlagReturnsMap, $getValueReturnsMap, 1, 11, true], + // StoreId 2. + [$isSetFlagReturnsMap, $getValueReturnsMap, 2, 0, true], + [$isSetFlagReturnsMap, $getValueReturnsMap, 2, 9, true], + [$isSetFlagReturnsMap, $getValueReturnsMap, 2, 10, true], + [$isSetFlagReturnsMap, $getValueReturnsMap, 2, 11, true], + // StoreId 3. + [$isSetFlagReturnsMap, $getValueReturnsMap, 3, 0, false], + [$isSetFlagReturnsMap, $getValueReturnsMap, 3, 9, false], + [$isSetFlagReturnsMap, $getValueReturnsMap, 3, 10, true], + [$isSetFlagReturnsMap, $getValueReturnsMap, 3, 11, true], + ]; + } + + /** + * Get Container configuration mock. + * + * @return MockObject|ContainerConfigurationInterface + */ + private function getContainerConfigurationInterfaceMock() + { + $containerConfiguration = $this->getMockBuilder(ContainerConfigurationInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + return $containerConfiguration; + } + + /** + * Get Scope config mock. + * + * @return MockObject|ScopeConfigInterface + */ + private function getScopeConfigInterfaceMock() + { + $scopeConfig = $this->getMockBuilder(ScopeConfigInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + return $scopeConfig; + } +} diff --git a/src/module-elasticsuite-thesaurus/Test/Unit/Model/IndexTest.php b/src/module-elasticsuite-thesaurus/Test/Unit/Model/IndexTest.php new file mode 100644 index 000000000..6c7f408b5 --- /dev/null +++ b/src/module-elasticsuite-thesaurus/Test/Unit/Model/IndexTest.php @@ -0,0 +1,1858 @@ + + * @copyright 2024 Smile + * @license Open Software License ("OSL") v. 3.0 + */ + +declare(strict_types = 1); + +namespace Smile\ElasticsuiteThesaurus\Test\Unit\Model; + +use Elasticsearch\Common\Exceptions\BadRequest400Exception; +use PHPUnit\Framework\MockObject\MockObject; +use Smile\ElasticsuiteCore\Api\Client\ClientInterface; +use Smile\ElasticsuiteCore\Api\Search\Request\ContainerConfigurationInterface; +use Smile\ElasticsuiteCore\Helper\Cache; +use Smile\ElasticsuiteCore\Helper\IndexSettings; +use Smile\ElasticsuiteThesaurus\Config\ThesaurusCacheConfig; +use Smile\ElasticsuiteThesaurus\Config\ThesaurusConfig; +use Smile\ElasticsuiteThesaurus\Config\ThesaurusConfigFactory; +use Smile\ElasticsuiteThesaurus\Model\Index as ThesaurusIndex; + +/** + * Thesaurus Index model unit test. + * @SuppressWarnings(PHPMD.ExcessiveParameterList) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * @SuppressWarnings(PHPMD.ExcessiveClassLength) + * + * @category Smile + * @package Smile\ElasticsuiteThesaurus + */ +class IndexTest extends \PHPUnit\Framework\TestCase +{ + /** + * Test cache usage and lack of rewrites. + * @dataProvider noRewriteDataProvider + * + * @param string $queryText Initial query text. + * @param bool $synonymsEnabled Thesaurus config synonym enabled switch. + * @param int $synonymWeightDivider Thesaurus config synonym weight divider. + * @param bool $expansionEnabled Thesaurus config expansion enabled switch. + * @param int $expansionWeightDivider Thesaurus config expansion weight divider. + * @param int $maxRewrites Thesaurus config max rewrites. + * @param int $timesClientCalled Expected number of times the client 'analyze' method will be called. + * @param array $clientConsecutiveReturns Array of mocked returns from client 'analyze' method. + * @param array $expectedRewrites Expected final array of rewritten queries. + * @param bool $cacheStorageAllowed Whether saving results in cache is allowed. + * @param string $containerName Container name/request type. + * @param int $storeId Store id. + * @param string $storeCode Store code. + * @param string $indexPrefix Global config index alias/prefix. + * + * @return void + */ + public function testCacheUsageNoRewrites( + $queryText, + $synonymsEnabled, + $synonymWeightDivider, + $expansionEnabled, + $expansionWeightDivider, + $maxRewrites, + $timesClientCalled, + $clientConsecutiveReturns, + $expectedRewrites, + $cacheStorageAllowed = true, + $containerName = 'requestType', + $storeId = 1, + $storeCode = 'default', + $indexPrefix = 'magento2' + ) { + $clientMock = $this->getClientMock(); + $indexSettingsHelperMock = $this->getIndexSettingsHelperMock(); + $cacheHelperMock = $this->getCacheHelperMock(); + $thesaurusConfigMock = $this->getThesaurusConfigMock( + $synonymsEnabled, + $synonymWeightDivider, + $expansionEnabled, + $expansionWeightDivider, + $maxRewrites + ); + $thesaurusConfigFactoryMock = $this->getThesaurusConfigFactoryMock($thesaurusConfigMock); + $thesaurusCacheConfigMock = $this->getThesaurusCacheConfigMock($cacheStorageAllowed); + + $indexAlias = sprintf('%s_%s_%s', $indexPrefix, $storeCode, ThesaurusIndex::INDEX_IDENTIER); + $indexSettingsHelperMock->method('getIndexAliasFromIdentifier')->willReturn($indexAlias); + + $clientMock->expects($this->exactly($timesClientCalled))->method('analyze') + ->willReturnOnConsecutiveCalls( + $clientConsecutiveReturns + ); + + $containerConfig = $this->getMockBuilder(ContainerConfigurationInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $containerConfig->method('getStoreId')->willReturn($storeId); + $containerConfig->method('getName')->willReturn($containerName); + + $thesaurusIndex = new ThesaurusIndex( + $clientMock, + $indexSettingsHelperMock, + $cacheHelperMock, + $thesaurusConfigFactoryMock, + $thesaurusCacheConfigMock + ); + + $cacheKey = implode('|', [$indexAlias, $containerName, $queryText]); + $cacheTags = [$indexAlias, $containerName]; + + $cacheHelperMock->expects($this->exactly(1))->method('loadCache')->with($cacheKey) + ->willReturn(false); + $cacheHelperMock->expects($this->exactly((int) $cacheStorageAllowed))->method('saveCache')->with( + $cacheKey, + $expectedRewrites, + $cacheTags + ); + + $rewrites = $thesaurusIndex->getQueryRewrites($containerConfig, $queryText); + $this->assertEquals($expectedRewrites, $rewrites); + } + + /** + * Data provider for testCacheUsageNoRewrites. + * + * @return array + */ + public function noRewriteDataProvider() + { + /* + * [queryText, synonymsEnabled, $synonymWeightDivider, expansionEnabled, expansionWeightDivider, $maxRewrites, + * timesClientCall, clientConsecutiveReturns, expectedRewrites(, $cacheStorageAllowed)]. + */ + return [ + ['foo', false, 10, false, 10, 2, + 0, [], [], + ], + ['foo', true, 10, false, 10, 2, + 1, [[]], [], + ], + ['foo', true, 10, true, 10, 2, + 2, [[], []], [], + ], + ['foo', true, 10, true, 10, 2, + 2, [[], []], [], false, + ], + ]; + } + + /** + * Test single level rewrites. + * @dataProvider singleLevelRewritesDataProvider + * @SuppressWarnings(PHPMD.ElseExpression) + * + * @param string $queryText Initial query text. + * @param bool $synonymsEnabled Thesaurus config synonym enabled switch. + * @param int $synonymWeightDivider Thesaurus config synonym weight divider. + * @param bool $expansionEnabled Thesaurus config expansion enabled switch. + * @param int $expansionWeightDivider Thesaurus config expansion weight divider. + * @param int $maxRewrites Thesaurus config max rewrites. + * @param int $timesClientCalled Expected number of times the client 'analyze' method will be called. + * @param array $clientConsecutiveReturns Array of mocked returns from client 'analyze' method. + * @param array $expectedRewrites Expected final array of rewritten queries. + * @param bool $cacheStorageAllowed Whether saving results in cache is allowed. + * @param string $containerName Container name/request type. + * @param int $storeId Store id. + * @param string $storeCode Store code. + * @param string $indexPrefix Global config index alias/prefix. + * + * @return void + */ + public function testSingleLevelRewrites( + $queryText, + $synonymsEnabled, + $synonymWeightDivider, + $expansionEnabled, + $expansionWeightDivider, + $maxRewrites, + $timesClientCalled, + $clientConsecutiveReturns, + $expectedRewrites, + $cacheStorageAllowed = true, + $containerName = 'requestType', + $storeId = 1, + $storeCode = 'default', + $indexPrefix = 'magento2' + ) { + $clientMock = $this->getClientMock(); + $indexSettingsHelperMock = $this->getIndexSettingsHelperMock(); + $cacheHelperMock = $this->getCacheHelperMock(); + $thesaurusConfigMock = $this->getThesaurusConfigMock( + $synonymsEnabled, + $synonymWeightDivider, + $expansionEnabled, + $expansionWeightDivider, + $maxRewrites + ); + $thesaurusConfigFactoryMock = $this->getThesaurusConfigFactoryMock($thesaurusConfigMock); + $thesaurusCacheConfigMock = $this->getThesaurusCacheConfigMock($cacheStorageAllowed); + + $indexAlias = sprintf('%s_%s_%s', $indexPrefix, $storeCode, ThesaurusIndex::INDEX_IDENTIER); + $indexSettingsHelperMock->method('getIndexAliasFromIdentifier')->willReturn($indexAlias); + + $analyzeMethod = $clientMock->expects($this->exactly($timesClientCalled))->method('analyze'); + if (array_key_exists('map', $clientConsecutiveReturns)) { + $clientReturnMaps = $clientConsecutiveReturns['map']; + $clientReturnMaps = array_map( + function ($mapItem) use ($indexAlias) { + $baseParams = current($mapItem); + $results = next($mapItem); + + return [['index' => $indexAlias, 'body' => $baseParams], $results]; + }, + $clientReturnMaps + ); + $analyzeMethod->willReturnMap($clientReturnMaps); + } else { + $analyzeMethod->willReturnOnConsecutiveCalls(...$clientConsecutiveReturns); + } + + $containerConfig = $this->getMockBuilder(ContainerConfigurationInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $containerConfig->method('getStoreId')->willReturn($storeId); + $containerConfig->method('getName')->willReturn($containerName); + + $thesaurusIndex = new ThesaurusIndex( + $clientMock, + $indexSettingsHelperMock, + $cacheHelperMock, + $thesaurusConfigFactoryMock, + $thesaurusCacheConfigMock + ); + + $cacheKey = implode('|', array_merge([$indexAlias, $containerName], [$queryText])); + $cacheTags = [$indexAlias, $containerName]; + + $cacheHelperMock->expects($this->exactly(1))->method('loadCache')->with($cacheKey) + ->willReturn(false); + $cacheHelperMock->expects($this->exactly((int) $cacheStorageAllowed))->method('saveCache')->with( + $cacheKey, + $expectedRewrites, + $cacheTags + ); + + $rewrites = $thesaurusIndex->getQueryRewrites($containerConfig, $queryText); + $this->assertEquals($expectedRewrites, $rewrites); + } + + /** + * Data provider for testSingleLevelRewrites. + * + * @return array + */ + public function singleLevelRewritesDataProvider() + { + /* + * [queryText, synonymsEnabled, $synonymWeightDivider, expansionEnabled, expansionWeightDivider, $maxRewrites, + * timesClientCall, clientConsecutiveReturns, expectedRewrites(, cacheStorageAllowed)]. + */ + return [ + // Both synonyms and expansions disabled. + ['foo', false, 10, false, 10, 2, + 0, [], [], + ], + // Only synonyms enabled. Simulating 'foo,bar,baz'. + ['foo', true, 10, false, 10, 2, + 1, + [ + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'bar', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'baz', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + ], + ], + ], + [ + 'bar' => 0.1, + 'baz' => 0.1, + ], + ], + // Only synonyms enabled. Simulating 'foo,bar,baz'. + // Same test as before, but client->analyze returns expressed as a mapping. + ['foo', true, 10, false, 10, 2, + 1, + [ + 'map' => [ + [ + ['text' => 'foo', 'analyzer' => 'synonym'], + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'bar', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'baz', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + ], + ], + ], + ], + ], + [ + 'bar' => 0.1, + 'baz' => 0.1, + ], + ], + // Only expansions enabled. Simulating 'foo => bar,baz'. + ['foo', false, 10, true, 10, 2, + 1, + [ + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'bar', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'baz', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + ], + ], + ], + [ + 'bar' => 0.1, + 'baz' => 0.1, + ], + ], + // Only expansions enabled. Simulating 'foo => bar,baz'. + ['foo', false, 10, true, 10, 2, + 1, + [ + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'bar', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'baz', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + ], + ], + ], + [ + 'bar' => 0.1, + 'baz' => 0.1, + ], + ], + // Both synonyms and expansions enabled. Simulating 'foo,bar,baz' and 'bar => pub,cafe'. + ['foo', true, 10, true, 10, 2, + 4, + [ + // Synonyms call for 'foo'. + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'bar', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'baz', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + ], + ], + // Expansions call. + // No expansion for 'foo'. + ['tokens' => []], + // Expansion for 'bar'. + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'pub', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'cafe', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + ], + ], + // No expansion for 'baz'. + ['tokens' => []], + ], + [ + // Synonyms only for 'foo'. + 'bar' => 0.1, + 'baz' => 0.1, + 'pub' => 0.01, + 'cafe' => 0.01, + ], + ], + // Both synonyms and expansions enabled. Simulating 'foo,bar,baz' and 'bar => pub,cafe'. + // No cache storage allowed. + ['foo', true, 10, true, 10, 2, + 4, + [ + // Synonyms call for 'foo'. + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'bar', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'baz', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + ], + ], + // Expansions call. + // No expansion for 'foo'. + ['tokens' => []], + // Expansion for 'bar'. + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'pub', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'cafe', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + ], + ], + // No expansion for 'baz'. + ['tokens' => []], + ], + [ + // Synonyms only for 'foo'. + 'bar' => 0.1, + 'baz' => 0.1, + 'pub' => 0.01, + 'cafe' => 0.01, + ], + false, + ], + // Both synonyms and expansions enabled, multi-words search. + // Simulating 'foo,bat,baz' and 'bar => pub,cafe'. + // Carefull, the client is also called in getQueryCombinations. + ['foo bar', true, 10, true, 10, 2, + 12, + [ + // Synonyms::getQueryCombinations call for 'foo bar'. + [ + 'tokens' => [ + [ + 'type' => 'shingle', + 'token' => 'foo_bar', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 0, + ], + ], + ], + // Synonyms call for 'foo bar'. + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'bat', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'baz', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + ], + ], + // Synonyms call for 'foo_bar'. + ['tokens' => []], + // Expansion::getQueryCombinations call for 'foo bar'. + [ + 'tokens' => [ + [ + 'type' => 'shingle', + 'token' => 'foo_bar', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 0, + ], + ], + ], + // Expansion call for '(foo) bar'. + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'pub', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 1, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'cafe', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 1, + ], + ], + ], + // Expansion call for 'foo_bar'. + ['tokens' => []], + // Expansion::getQueryCombinations call for 'bat bar'. + [ + 'tokens' => [ + [ + 'type' => 'shingle', + 'token' => 'bat_bar', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 0, + ], + ], + ], + // Expansion call for '(bat) bar'. + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'pub', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 1, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'cafe', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 1, + ], + ], + ], + // Expansion call for 'bat_bar'. + ['tokens' => []], + // Expansion::getQueryCombinations call for 'baz bar'. + [ + 'tokens' => [ + [ + 'type' => 'shingle', + 'token' => 'baz_bar', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 0, + ], + ], + ], + // Expansion call for '(baz) bar'. + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'pub', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 1, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'cafe', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 1, + ], + ], + ], + // Expansion call for 'baz_bar'. + ['tokens' => []], + ], + [ + // Synonyms only for 'foo (bar)'. + 'bat bar' => 0.1, + 'baz bar' => 0.1, + // Expansions only for '(foo) bar'. + 'foo pub' => 0.1, + 'foo cafe' => 0.1, + // Expansions for '(bat) bar'. + 'bat pub' => 0.01, + 'bat cafe' => 0.01, + // Expansions for '(baz) bar'. + 'baz pub' => 0.01, + 'baz cafe' => 0.01, + ], + ], + ]; + } + + /** + * Test multi-level rewrites combination. + * @dataProvider multiLevelRewritesDataProvider + * @SuppressWarnings(PHPMD.ElseExpression) + * + * @param string $queryText Initial query text. + * @param bool $synonymsEnabled Thesaurus config synonym enabled switch. + * @param int $synonymWeightDivider Thesaurus config synonym weight divider. + * @param bool $expansionEnabled Thesaurus config expansion enabled switch. + * @param int $expansionWeightDivider Thesaurus config expansion weight divider. + * @param int $maxRewrites Thesaurus config max rewrites. + * @param int $timesClientCalled Expected number of times the client 'analyze' method will be called. + * @param array $clientConsecutiveReturns Array of mocked returns from client 'analyze' method. + * @param array $expectedRewrites Expected final array of rewritten queries. + * @param bool $cacheStorageAllowed Whether saving results in cache is allowed. + * @param string $containerName Container name/request type. + * @param int $storeId Store id. + * @param string $storeCode Store code. + * @param string $indexPrefix Global config index alias/prefix. + * + * @return void + */ + public function testMultiLevelRewritesCombination( + $queryText, + $synonymsEnabled, + $synonymWeightDivider, + $expansionEnabled, + $expansionWeightDivider, + $maxRewrites, + $timesClientCalled, + $clientConsecutiveReturns, + $expectedRewrites, + $cacheStorageAllowed = true, + $containerName = 'requestType', + $storeId = 1, + $storeCode = 'default', + $indexPrefix = 'magento2' + ) { + $clientMock = $this->getClientMock(); + $indexSettingsHelperMock = $this->getIndexSettingsHelperMock(); + $cacheHelperMock = $this->getCacheHelperMock(); + $thesaurusConfigMock = $this->getThesaurusConfigMock( + $synonymsEnabled, + $synonymWeightDivider, + $expansionEnabled, + $expansionWeightDivider, + $maxRewrites + ); + $thesaurusConfigFactoryMock = $this->getThesaurusConfigFactoryMock($thesaurusConfigMock); + $thesaurusCacheConfigMock = $this->getThesaurusCacheConfigMock($cacheStorageAllowed); + + $indexAlias = sprintf('%s_%s_%s', $indexPrefix, $storeCode, ThesaurusIndex::INDEX_IDENTIER); + $indexSettingsHelperMock->method('getIndexAliasFromIdentifier')->willReturn($indexAlias); + + $analyzeMethod = $clientMock->expects($this->exactly($timesClientCalled))->method('analyze'); + if (array_key_exists('map', $clientConsecutiveReturns)) { + $clientReturnMaps = $clientConsecutiveReturns['map']; + $clientReturnMaps = array_map( + function ($mapItem) use ($indexAlias) { + $baseParams = current($mapItem); + $results = next($mapItem); + + return [['index' => $indexAlias, 'body' => $baseParams], $results]; + }, + $clientReturnMaps + ); + $analyzeMethod->willReturnMap($clientReturnMaps); + } else { + $analyzeMethod->willReturnOnConsecutiveCalls(...$clientConsecutiveReturns); + } + + $containerConfig = $this->getMockBuilder(ContainerConfigurationInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $containerConfig->method('getStoreId')->willReturn($storeId); + $containerConfig->method('getName')->willReturn($containerName); + + $thesaurusIndex = new ThesaurusIndex( + $clientMock, + $indexSettingsHelperMock, + $cacheHelperMock, + $thesaurusConfigFactoryMock, + $thesaurusCacheConfigMock + ); + + $cacheKey = implode('|', array_merge([$indexAlias, $containerName], [$queryText])); + $cacheTags = [$indexAlias, $containerName]; + + $cacheHelperMock->expects($this->exactly(1))->method('loadCache')->with($cacheKey) + ->willReturn(false); + $cacheHelperMock->expects($this->exactly((int) $cacheStorageAllowed))->method('saveCache')->with( + $cacheKey, + $expectedRewrites, + $cacheTags + ); + + $rewrites = $thesaurusIndex->getQueryRewrites($containerConfig, $queryText); + $this->assertEquals($expectedRewrites, $rewrites); + } + + /** + * Data provider for testMultiLevelRewritesCombination. + * + * @return array + */ + public function multiLevelRewritesDataProvider() + { + /* + * Results map for rules: + * synonyms: foo,bar' and 'foobar,foo bar' and 'bar,pipe,tube'. + * expansion: 'bar => pub,cafe'. + */ + $cyclingMappingResults = [ + 'map' => [ + [ + // Synonyms::getQueryCombinations call for 'foo bar'. + ['text' => 'foo bar', 'analyzer' => 'shingles'], + [ + 'tokens' => [ + [ + 'type' => 'shingle', + 'token' => 'foo_bar', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 0, + ], + ], + ], // => Produce queries ['foo bar', 'foo_bar']. + ], + [ + // Synonyms call for 'foo bar'. + ['text' => 'foo bar', 'analyzer' => 'synonym'], + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'bar', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'shingle', + 'token' => 'bar_foo', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 0, + 'positionLength' => 2, + ], + [ + 'type' => 'shingle', + 'token' => 'bar_foo_pipe', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 0, + 'positionLength' => 3, + ], + [ + 'type' => 'shingle', + 'token' => 'bar_foo_pipe_tube', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 0, + 'positionLength' => 4, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'foo', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 1, + ], + [ + 'type' => 'shingle', + 'token' => 'foo_pipe', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 1, + 'positionLength' => 2, + ], + [ + 'type' => 'shingle', + 'token' => 'foo_pipe_tube', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 1, + 'positionLength' => 3, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'pipe', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 2, + ], + [ + 'type' => 'shingle', + 'token' => 'pipe_tube', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 2, + 'positionLength' => 2, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'tube', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 3, + ], + ], + ], + ], + [ + // Synonyms call for 'foo_bar'. + ['text' => 'foo_bar', 'analyzer' => 'synonym'], + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'foobar', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 0, + ], + ], + ], + ], + [ + // Expansion call for '(foo) bar'. + ['text' => 'foo bar', 'analyzer' => 'expansion'], + [ + 'tokens' => [ + [ + 'type' => 'shingle', + 'token' => '__cafe', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 0, + 'positionLength' => 2, + ], + [ + 'type' => 'shingle', + 'token' => '__cafe_pub', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 0, + 'positionLength' => 3, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'cafe', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 1, + ], + [ + 'type' => 'shingle', + 'token' => 'cafe_pub', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 1, + 'positionLength' => 2, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'pub', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 2, + ], + ], + ], + ], + [ + // Expansion call for 'foo_bar'. + ['text' => 'foo_bar', 'analyzer' => 'expansion'], + ['tokens' => []], + ], + [ + // Expansion::getQueryCombinations call for 'bar bar'. + ['text' => 'bar bar', 'analyzer' => 'shingles'], + [ + 'tokens' => [ + [ + 'type' => 'shingle', + 'token' => 'bar_bar', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 0, + ], + ], + ], + ], + [ + // Expansion call for 'bar bar'. + ['text' => 'bar bar', 'analyzer' => 'expansion'], + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'cafe', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'shingle', + 'token' => 'cafe_pub', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + 'positionLength' => 2, + ], + [ + 'type' => 'shingle', + 'token' => 'cafe_pub_cafe', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 0, + 'positionLength' => 3, + ], + [ + 'type' => 'shingle', + 'token' => 'cafe_pub_cafe_pub', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 0, + 'positionLength' => 4, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'pub', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 1, + ], + [ + 'type' => 'shingle', + 'token' => 'pub_cafe', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 1, + 'positionLength' => 2, + ], + [ + 'type' => 'shingle', + 'token' => 'pub_cafe_pub', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 1, + 'positionLength' => 3, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'cafe', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 2, + ], + [ + 'type' => 'shingle', + 'token' => 'cafe_pub', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 2, + 'positionLength' => 2, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'pub', + 'start_offset' => 4, + 'end_offset' => 7, + 'position' => 3, + ], + ], + ], + ], + [ + // Expansion call for 'bar_bar'. + ['text' => 'bar_bar', 'analyzer' => 'expansion'], + ['tokens' => []], + ], + [ + // Expansion::getQueryCombinations call for 'bar foo'. + ['text' => 'bar foo', 'analyzer' => 'shingles'], + [ + 'tokens' => [ + [ + 'type' => 'shingle', + 'token' => 'bar_foo', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 0, + ], + ], + ], + ], + [ + ['text' => 'bar foo', 'analyzer' => 'expansion'], + // Expansion call for 'bar foo'. + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'cafe', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'shingle', + 'token' => 'cafe_pub', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + 'positionLength' => 2, + ], + [ + 'type' => 'shingle', + 'token' => 'cafe_pub__', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 0, + 'positionLength' => 3, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'pub', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 1, + ], + [ + 'type' => 'shingle', + 'token' => 'pub__', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 1, + 'positionLength' => 2, + ], + ], + ], + ], + [ + // Expansion call for 'bar_foo'. + ['text' => 'bar_foo', 'analyzer' => 'expansion'], + ['tokens' => []], + ], + [ + // Expansion::getQueryCombinations call for 'bar pipe'. + ['text' => 'bar pipe', 'analyzer' => 'shingles'], + [ + 'tokens' => [ + [ + 'type' => 'shingle', + 'token' => 'bar_pipe', + 'start_offset' => 0, + 'end_offset' => 8, + 'position' => 0, + ], + ], + ], + ], + [ + // Expansion call for 'bar pipe'. + ['text' => 'bar pipe', 'analyzer' => 'expansion'], + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'cafe', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'shingle', + 'token' => 'cafe_pub', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + 'positionLength' => 2, + ], + [ + 'type' => 'shingle', + 'token' => 'cafe_pub__', + 'start_offset' => 0, + 'end_offset' => 8, + 'position' => 0, + 'positionLength' => 3, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'pub', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 1, + ], + [ + 'type' => 'shingle', + 'token' => 'pub__', + 'start_offset' => 0, + 'end_offset' => 8, + 'position' => 1, + 'positionLength' => 2, + ], + ], + ], + ], + [ + // Expansion call for 'bar_pipe'. + ['text' => 'bar_pipe', 'analyzer' => 'expansion'], + ['tokens' => []], + ], + [ + // Expansion::getQueryCombinations call for 'bar tube'. + ['text' => 'bar tube', 'analyzer' => 'shingles'], + [ + 'tokens' => [ + [ + 'type' => 'shingle', + 'token' => 'bar_tube', + 'start_offset' => 0, + 'end_offset' => 8, + 'position' => 0, + ], + ], + ], + ], + [ + // Expansion call for 'bar tube'. + ['text' => 'bar tube', 'analyzer' => 'expansion'], + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'cafe', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'shingle', + 'token' => 'cafe_pub', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + 'positionLength' => 2, + ], + [ + 'type' => 'shingle', + 'token' => 'cafe_pub__', + 'start_offset' => 0, + 'end_offset' => 8, + 'position' => 0, + 'positionLength' => 3, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'pub', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 1, + ], + [ + 'type' => 'shingle', + 'token' => 'pub__', + 'start_offset' => 0, + 'end_offset' => 8, + 'position' => 1, + 'positionLength' => 2, + ], + ], + ], + ], + [ + // Expansion call for 'bar_tube'. + ['text' => 'bar_tube', 'analyzer' => 'expansion'], + ['tokens' => []], + ], + [ + // Expansion::getQueryCombinations call for 'foo foo'. + ['text' => 'foo foo', 'analyzer' => 'shingles'], + [ + 'tokens' => [ + [ + 'type' => 'shingle', + 'token' => 'foo_foo', + 'start_offset' => 0, + 'end_offset' => 7, + 'position' => 0, + ], + ], + ], + ], + [ + // Expansion call for 'foo foo'. + ['text' => 'foo foo', 'analyzer' => 'expansion'], + ['tokens' => []], + ], + [ + // Expansion call for 'foo_foo'. + ['text' => 'foo_foo', 'analyzer' => 'expansion'], + ['tokens' => []], + ], + [ + // Expansion::getQueryCombinations call for 'foo pipe'. + ['text' => 'foo pipe', 'analyzer' => 'shingles'], + [ + 'tokens' => [ + [ + 'type' => 'shingle', + 'token' => 'foo_pipe', + 'start_offset' => 0, + 'end_offset' => 8, + 'position' => 0, + ], + ], + ], + ], + [ + // Expansion call for 'foo pipe'. + ['text' => 'foo pipe', 'analyzer' => 'expansion'], + ['tokens' => []], + ], + [ + // Expansion call for 'foo pipe'. + ['text' => 'foo_pipe', 'analyzer' => 'expansion'], + ['tokens' => []], + ], + [ + // Expansion::getQueryCombinations call for 'foo tube'. + ['text' => 'foo tube', 'analyzer' => 'shingles'], + [ + 'tokens' => [ + [ + 'type' => 'shingle', + 'token' => 'foo_tube', + 'start_offset' => 0, + 'end_offset' => 8, + 'position' => 0, + ], + ], + ], + ], + [ + // Expansion call for 'foo tube'. + ['text' => 'foo tube', 'analyzer' => 'expansion'], + ['tokens' => []], + ], + [ + // Expansion call for 'foo_tube'. + ['text' => 'foo_tube', 'analyzer' => 'expansion'], + ['tokens' => []], + ], + [ + // Expansion call for 'foobar'. + ['text' => 'foobar', 'analyzer' => 'expansion'], + ['tokens' => []], + ], + ], + ]; + + /* + * [queryText, synonymsEnabled, $synonymWeightDivider, expansionEnabled, expansionWeightDivider, $maxRewrites, + * timesClientCall, clientConsecutiveReturns, expectedRewrites]. + */ + return [ + // Both synonyms and expansions disabled. + ['foo', false, 10, false, 10, 2, + 0, [], [], + ], + // Only synonyms enabled. Simulating 'foo,bar,baz' and 'bar,pub,cafe' and 'bar,pipe,tube'. + ['foo', true, 10, false, 10, 2, + 1, + [ + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'bar', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'shingle', + 'token' => 'bar_baz', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + 'positionLength' => 2, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'baz', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + ], + ], + ], + [ + 'bar' => 0.1, + 'baz' => 0.1, + ], + ], + // Only expansions enabled. Simulating 'foo => bar,baz' and 'bar => pub,cafe' and 'bar => pipe,tube'. + ['foo', false, 10, true, 10, 2, + 1, + [ + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'bar', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'shingle', + 'token' => 'bar_baz', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + 'positionLength' => 2, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'baz', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + ], + ], + ], + [ + 'bar' => 0.1, + 'baz' => 0.1, + ], + ], + // Both synonyms and expansions enabled. + // Simulating 'foo,bar,baz' and 'bar,pub,cafe' and 'bar,pipe,tube'. + // and 'foo => bar,baz' and 'bar => pub,cafe' and 'bar => pipe,tube'. + ['foo', true, 10, true, 100, 2, + 4, + [ + // Synonyms call for 'foo'. + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'bar', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'shingle', + 'token' => 'bar_baz', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + 'positionLength' => 2, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'baz', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + ], + ], + // Expansions call. + // No expansion for 'foo'. + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'bar', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'shingle', + 'token' => 'bar_baz', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + 'positionLength' => 2, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'baz', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + ], + ], + // Expansion for 'bar'. + [ + 'tokens' => [ + [ + 'type' => 'SYNONYM', + 'token' => 'cafe', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + ], + [ + 'type' => 'shingle', + 'token' => 'cafe_pub', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + 'positionLength' => 2, + ], + [ + 'type' => 'shingle', + 'token' => 'cafe_pub_pipe', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + 'positionLength' => 3, + ], + [ + 'type' => 'shingle', + 'token' => 'cafe_pub_pipe_tube', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 0, + 'positionLength' => 4, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'pub', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 1, + ], + [ + 'type' => 'shingle', + 'token' => 'pub_pipe', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 1, + 'positionLength' => 2, + ], + [ + 'type' => 'shingle', + 'token' => 'pub_pipe_tube', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 1, + 'positionLength' => 3, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'pipe', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 2, + ], + [ + 'type' => 'shingle', + 'token' => 'pipe_tube', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 2, + 'positionLength' => 2, + ], + [ + 'type' => 'SYNONYM', + 'token' => 'tube', + 'start_offset' => 0, + 'end_offset' => 3, + 'position' => 3, + ], + ], + ], + // No expansion for 'baz'. + ['tokens' => []], + ], + [ + // Synonyms only for 'foo'. + 'baz' => 0.1, + 'bar' => 0.1, + 'pub' => 0.001, + 'cafe' => 0.001, + 'pipe' => 0.001, + 'tube' => 0.001, + ], + ], + // Both synonyms and expansions enabled, multi-words search with cycle. + // Simulating 'foo,bar' and 'foobar,foo bar', 'bar,pipe,tube' and 'bar => pub,cafe'. + // Carefull, the client is also called in getQueryCombinations. + ['foo bar', true, 10, true, 10, 2, + 28, + $cyclingMappingResults, + [ + // Synonyms only for 'foo (bar)'. + 'bar bar' => 0.1, + // Synonyms only for 'bar bar' (2nd level). + 'bar foo' => 0.05, // 0.1 / (rewrite level) = 0.1 / 2. + 'bar pipe' => 0.05, // 0.1 / (rewrite level) = 0.1 / 2. + 'bar tube' => 0.05, // 0.1 / (rewrite level) = 0.1 / 2. + // Synonyms only for '(foo) bar'. + 'foo foo' => 0.1, + 'foo pipe' => 0.1, + 'foo tube' => 0.1, + // Synonyms only for 'foo bar'. + 'foobar' => 0.1, + // Expansions only for '(foo) bar'. + 'foo cafe' => 0.1, + 'foo pub' => 0.1, + // Expansions for 'bar (bar)'. + 'cafe bar' => 0.01, + // 2nd level expansions. + 'cafe cafe' => 0.005, + 'cafe pub' => 0.005, + 'pub bar' => 0.01, + // 2nd level expansions. + 'pub cafe' => 0.005, + 'pub pub' => 0.005, + // Expansions for '(bar) bar'. + 'bar cafe' => 0.01, + 'bar pub' => 0.01, + // 2nd level expansions. + 'cafe foo' => 0.005, + 'pub foo' => 0.005, + // Expansion for 'bar pipe'. + 'cafe pipe' => 0.005, + 'pub pipe' => 0.005, + // Expansion for 'bar tube'. + 'cafe tube' => 0.005, + 'pub tube' => 0.005, + ], + ], + // Both synonyms and expansions enabled, multi-words search with cycle with limiting max rewrites to 1. + // Simulating 'foo,bar' and 'foobar,foo bar', 'bar,pipe,tube' and 'bar => pub,cafe'. + // Carefull, the client is also called in getQueryCombinations. + ['foo bar', true, 10, true, 10, 1, + 19, + $cyclingMappingResults, + [ + // Synonyms only for 'foo (bar)'. + 'bar bar' => 0.1, + // No 2nd level synonyms. + /* + 'bar foo' => 0.05, // 0.1 / (rewrite level) = 0.1 / 2. + 'bar pipe' => 0.05, // 0.1 / (rewrite level) = 0.1 / 2. + 'bar tube' => 0.05, // 0.1 / (rewrite level) = 0.1 / 2. + */ + // Synonyms only for '(foo) bar'. + 'foo foo' => 0.1, + 'foo pipe' => 0.1, + 'foo tube' => 0.1, + // Synonyms only for 'foo bar'. + 'foobar' => 0.1, + // Expansions only for '(foo) bar'. + 'foo cafe' => 0.1, + 'foo pub' => 0.1, + // Expansions for 'bar (bar)'. + 'cafe bar' => 0.01, + // No 2nd level expansions. + /* + * ex: + 'cafe cafe' => 0.005, + 'cafe pub' => 0.005, + */ + 'pub bar' => 0.01, + // Expansions for '(bar) bar'. + 'bar cafe' => 0.01, + 'bar pub' => 0.01, + ], + ], + ]; + } + + /** + * Test behavior when analysis call fails. + * @dataProvider withAnalysisFailureDataProvider + * + * @param string $queryText Initial query text. + * @param bool $synonymsEnabled Thesaurus config synonym enabled switch. + * @param int $synonymWeightDivider Thesaurus config synonym weight divider. + * @param bool $expansionEnabled Thesaurus config expansion enabled switch. + * @param int $expansionWeightDivider Thesaurus config expansion weight divider. + * @param int $maxRewrites Thesaurus config max rewrites. + * @param int $timesClientCalled Expected number of times the client 'analyze' method will be called. + * @param array $expectedRewrites Expected final array of rewritten queries. + * @param bool $cacheStorageAllowed Whether saving results in cache is allowed. + * @param string $containerName Container name/request type. + * @param int $storeId Store id. + * @param string $storeCode Store code. + * @param string $indexPrefix Global config index alias/prefix. + * + * @return void + */ + public function testAnalyzeFailure( + $queryText, + $synonymsEnabled, + $synonymWeightDivider, + $expansionEnabled, + $expansionWeightDivider, + $maxRewrites, + $timesClientCalled, + $expectedRewrites, + $cacheStorageAllowed = true, + $containerName = 'requestType', + $storeId = 1, + $storeCode = 'default', + $indexPrefix = 'magento2' + ) { + $clientMock = $this->getClientMock(); + $indexSettingsHelperMock = $this->getIndexSettingsHelperMock(); + $cacheHelperMock = $this->getCacheHelperMock(); + $thesaurusConfigMock = $this->getThesaurusConfigMock( + $synonymsEnabled, + $synonymWeightDivider, + $expansionEnabled, + $expansionWeightDivider, + $maxRewrites + ); + $thesaurusConfigFactoryMock = $this->getThesaurusConfigFactoryMock($thesaurusConfigMock); + $thesaurusCacheConfigMock = $this->getThesaurusCacheConfigMock($cacheStorageAllowed); + + $indexAlias = sprintf('%s_%s_%s', $indexPrefix, $storeCode, ThesaurusIndex::INDEX_IDENTIER); + $indexSettingsHelperMock->method('getIndexAliasFromIdentifier')->willReturn($indexAlias); + + $clientMock->expects($this->exactly($timesClientCalled))->method('analyze') + ->willThrowException(new BadRequest400Exception('Dummy exception')); + + $containerConfig = $this->getMockBuilder(ContainerConfigurationInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $containerConfig->method('getStoreId')->willReturn($storeId); + $containerConfig->method('getName')->willReturn($containerName); + + $thesaurusIndex = new ThesaurusIndex( + $clientMock, + $indexSettingsHelperMock, + $cacheHelperMock, + $thesaurusConfigFactoryMock, + $thesaurusCacheConfigMock + ); + + $cacheKey = implode('|', array_merge([$indexAlias, $containerName], [$queryText])); + $cacheTags = [$indexAlias, $containerName]; + + $cacheHelperMock->expects($this->exactly(1))->method('loadCache')->with($cacheKey) + ->willReturn(false); + $cacheHelperMock->expects($this->exactly((int) $cacheStorageAllowed))->method('saveCache')->with( + $cacheKey, + $expectedRewrites, + $cacheTags + ); + + $rewrites = $thesaurusIndex->getQueryRewrites($containerConfig, $queryText); + $this->assertEquals($expectedRewrites, $rewrites); + } + + /** + * With analysis failure data provider. + * + * @return array + */ + public function withAnalysisFailureDataProvider() + { + return [ + /* + * [queryText, containerName, storeId, storeCode, indexPrefix, + * synonymsEnabled, $synonymWeightDivider, expansionEnabled, expansionWeightDivider, $maxRewrites, + * timesClientCall, expectedRewrites]. + */ + // Both synonyms and expansions disabled. + ['foo', false, 10, false, 10, 2, 0, []], + // Only synonyms enabled. + ['foo', true, 10, false, 10, 2, 1, []], + // Only expansions enabled. + ['foo', false, 10, true, 10, 2, 1, []], + // Both synonyms and expansions enabled. + ['foo', true, 10, true, 10, 2, 2, []], + // Both synonyms and expansions enabled, multi-words search. + // Careful, the client is also called in getQueryCombinations. + ['foo bar', true, 10, true, 10, 2, 4, []], + ]; + } + + /** + * Get client mock. + * + * @return MockObject|ClientInterface + */ + private function getClientMock() + { + return $this->getMockBuilder(ClientInterface::class) + ->disableOriginalConstructor() + ->getMock(); + } + + /** + * Get Index settings helper mock. + * + * @return MockObject|IndexSettings + */ + private function getIndexSettingsHelperMock() + { + return $this->getMockBuilder(IndexSettings::class) + ->disableOriginalConstructor() + ->getMock(); + } + + /** + * Get Thesaurus Config mock object. + * + * @param bool $synonymEnabled Whether synonyms are enabled. + * @param int $synonymWeightDivider Synonyms weight divider. + * @param bool $expansionEnabled Whether expansions are enabled. + * @param int $expansionWeightDivider Expansion weight divider. + * @param int $maxRewrites Max allowed rewrites. + * + * @return MockObject + */ + private function getThesaurusConfigMock( + $synonymEnabled = true, + $synonymWeightDivider = 10, + $expansionEnabled = true, + $expansionWeightDivider = 10, + $maxRewrites = 2 + ) { + $thesaurusConfigMock = $this->getMockBuilder(ThesaurusConfig::class) + ->disableOriginalConstructor() + ->getMock(); + + $thesaurusConfigMock->method('isSynonymSearchEnabled')->willReturn($synonymEnabled); + $thesaurusConfigMock->method('getSynonymWeightDivider')->willReturn($synonymWeightDivider); + $thesaurusConfigMock->method('isExpansionSearchEnabled')->willReturn($expansionEnabled); + $thesaurusConfigMock->method('getExpansionWeightDivider')->willReturn($expansionWeightDivider); + $thesaurusConfigMock->method('getMaxRewrites')->willReturn($maxRewrites); + + return $thesaurusConfigMock; + } + + /** + * Get Thesaurus config factory + * + * @param MockObject|ThesaurusConfig $thesaurusConfig Thesaurus config. + * + * @return MockObject|ThesaurusConfigFactory + */ + private function getThesaurusConfigFactoryMock($thesaurusConfig) + { + $thesaurusConfigFactory = $this->getMockBuilder(ThesaurusConfigFactory::class) + ->disableOriginalConstructor() + ->getMock(); + + $thesaurusConfigFactory->method('create')->willReturn($thesaurusConfig); + + return $thesaurusConfigFactory; + } + + /** + * Get Thesaurus cache config mock. + * + * @param bool $cacheStorageAllowed Whether cache storage of results is allowed + * + * @return MockObject|ThesaurusCacheConfig + */ + private function getThesaurusCacheConfigMock($cacheStorageAllowed) + { + $thesaurusCacheConfig = $this->getMockBuilder(ThesaurusCacheConfig::class) + ->disableOriginalConstructor() + ->getMock(); + + $thesaurusCacheConfig->method('isCacheStorageAllowed')->willReturn($cacheStorageAllowed); + + return $thesaurusCacheConfig; + } + + /** + * Get Elasticsuite cache helper mock. + * + * @return MockObject|Cache + */ + private function getCacheHelperMock() + { + return $this->getMockBuilder(Cache::class) + ->disableOriginalConstructor() + ->getMock(); + } +} diff --git a/src/module-elasticsuite-thesaurus/Test/Unit/Plugin/QueryRewriteTest.php b/src/module-elasticsuite-thesaurus/Test/Unit/Plugin/QueryRewriteTest.php index bc139cb4c..cc7ad62ac 100644 --- a/src/module-elasticsuite-thesaurus/Test/Unit/Plugin/QueryRewriteTest.php +++ b/src/module-elasticsuite-thesaurus/Test/Unit/Plugin/QueryRewriteTest.php @@ -90,8 +90,9 @@ public function testMultipleSearchQueryDepthBuilder() $queryFactory = $this->getQueryFactory($this->mockedQueryTypes); $containerConfig = $this->getContainerConfigMock($this->fields); $spellingType = SpellcheckerInterface::SPELLING_TYPE_EXACT; + $maxRewrittenQueries = 0; - $thesaurusConfigFactory = $this->getThesaurusConfigFactoryMock(); + $thesaurusConfigFactory = $this->getThesaurusConfigFactoryMock($maxRewrittenQueries); $thesaurusIndex = $this->getMockBuilder(ThesaurusIndex::class) ->disableOriginalConstructor() @@ -133,8 +134,9 @@ public function testMultipleSearchQueryDepthBuilderWithRewrites() $queryFactory = $this->getQueryFactory($this->mockedQueryTypes); $containerConfig = $this->getContainerConfigMock($this->fields); $spellingType = SpellcheckerInterface::SPELLING_TYPE_EXACT; + $maxRewrittenQueries = 0; - $thesaurusConfigFactory = $this->getThesaurusConfigFactoryMock(); + $thesaurusConfigFactory = $this->getThesaurusConfigFactoryMock($maxRewrittenQueries); $thesaurusIndex = $this->getMockBuilder(ThesaurusIndex::class) ->disableOriginalConstructor() @@ -157,6 +159,76 @@ public function testMultipleSearchQueryDepthBuilderWithRewrites() $this->assertEquals(QueryInterface::TYPE_BOOL, $query->getType()); } + /** + * Test running the query builder using a single search expression and application of rewrites limitation + * per search term while the thesaurus index provides all rewrites. + * + * @return void + */ + public function testSingleSearchQueryLimitedRewrites() + { + $queryFactory = $this->getQueryFactory($this->mockedQueryTypes); + $queryFactoryFullMock = $this->getMockBuilder(QueryFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $containerConfig = $this->getContainerConfigMock($this->fields); + $spellingType = SpellcheckerInterface::SPELLING_TYPE_EXACT; + $maxRewrittenQueries = 1; + + $thesaurusConfigFactory = $this->getThesaurusConfigFactoryMock($maxRewrittenQueries); + + $thesaurusIndex = $this->getMockBuilder(ThesaurusIndex::class) + ->disableOriginalConstructor() + ->getMock(); + + // Passing the mock Query Factory to the plugin to count the occurence of calls to 'create'. + $queryRewritePlugin = new QueryRewrite($queryFactoryFullMock, $thesaurusConfigFactory, $thesaurusIndex); + // But passing the real Query Factory (with mocked factories) to the query builder itself. + $queryBuilderInterceptor = $this->getQueryBuilderWithPlugin($queryFactory, $queryRewritePlugin); + + $thesaurusIndex->expects($this->exactly(1))->method('getQueryRewrites')->withConsecutive( + [$containerConfig, 'foo', 1] + )->willReturnMap( + [ + [$containerConfig, 'foo', 1, ['foo bar' => 0.1, 'foo light' => 0.1, 'moo' => 0.1, 'moo bar' => 0.01]], + ] + ); + + $queryFactoryFullMock->expects($this->exactly(1))->method('create')->with( + $this->equalTo(QueryInterface::TYPE_BOOL), + $this->callback( + function ($createArguments) use ($maxRewrittenQueries) { + if (!is_array($createArguments) + || count($createArguments) > 1 + || !array_key_exists('should', $createArguments) + || !is_array($createArguments['should']) + ) { + return false; + } + $queries = $createArguments['should']; + // The initial query needs to be counted. + if (count($queries) > (1 + $maxRewrittenQueries)) { + return false; + } + foreach ($queries as $query) { + if (false == ($query instanceof QueryInterface)) { + return false; + } + /** @var QueryInterface $query */ + if ($query->getType() !== QueryInterface::TYPE_FILTER) { + return false; + } + } + + return true; + } + ) + ); + + /** @var \Smile\ElasticsuiteCore\Search\Request\Query\Boolean $query */ + $query = $queryBuilderInterceptor->create($containerConfig, 'foo', $spellingType); + } + /** * Get a fulltext query builder with a configured query rewrite plugin. * @@ -228,14 +300,16 @@ private function getQueryFactory($queryTypes) /** * Mock the thesaurus config factory. * + * @param int $maxRewrittenQueries Max Rewritten Queries. + * * @return \PHPUnit\Framework\MockObject\MockObject */ - private function getThesaurusConfigFactoryMock() + private function getThesaurusConfigFactoryMock($maxRewrittenQueries) { $thesaurusConfig = $this->getMockBuilder(ThesaurusConfig::class) ->disableOriginalConstructor() ->getMock(); - $thesaurusConfig->method('getMaxRewrittenQueries')->will($this->returnValue(0)); + $thesaurusConfig->method('getMaxRewrittenQueries')->will($this->returnValue($maxRewrittenQueries)); $thesaurusConfigFactory = $this->getMockBuilder(ThesaurusConfigFactory::class) ->disableOriginalConstructor() diff --git a/src/module-elasticsuite-thesaurus/etc/adminhtml/system.xml b/src/module-elasticsuite-thesaurus/etc/adminhtml/system.xml new file mode 100644 index 000000000..b3a14204e --- /dev/null +++ b/src/module-elasticsuite-thesaurus/etc/adminhtml/system.xml @@ -0,0 +1,42 @@ + + + +
+ + smile_elasticsuite + Smile_ElasticsuiteThesaurus::manage + + + + + + Magento\Config\Model\Config\Source\Yesno + + + + + integer validate-zero-or-greater + + 0 + + + + +
+
+
diff --git a/src/module-elasticsuite-thesaurus/etc/config.xml b/src/module-elasticsuite-thesaurus/etc/config.xml new file mode 100644 index 000000000..cb0a0071c --- /dev/null +++ b/src/module-elasticsuite-thesaurus/etc/config.xml @@ -0,0 +1,27 @@ + + + + + + + 1 + 0 + + + + diff --git a/src/module-elasticsuite-thesaurus/i18n/en_US.csv b/src/module-elasticsuite-thesaurus/i18n/en_US.csv index af4bc84f6..5d7db4dc5 100644 --- a/src/module-elasticsuite-thesaurus/i18n/en_US.csv +++ b/src/module-elasticsuite-thesaurus/i18n/en_US.csv @@ -68,3 +68,9 @@ Unselect Visible,Unselect Visible "Total of %1 thesaurus were disabled.","Total of %1 thesaurus were disabled." "Please select thesaurus to enable.","Please select thesaurus to enable." "Total of %1 thesaurus were enabled.","Total of %1 thesaurus were enabled." +"Thesaurus (global settings)","Thesaurus (global settings)" +"Cache settings","Cache settings" +"Always cache thesaurus application results","Always cache thesaurus application results" +"Set this to ""No"" to conditionally cache the results of user search query rewriting by active thesaurus rules. This can be used in peak trafic times to reduce the impact on your cache component with a trade-off of an increased volume of analysis requests sent to Elasticsearch/Opensearch and increased CPU usage.","Set this to ""No"" to conditionally cache the results of user search query rewriting by active thesaurus rules. This can be used in peak trafic times to reduce the impact on your cache component with a trade-off of an increased volume of analysis requests sent to Elasticsearch/Opensearch and increased CPU usage." +"Minimum amount of alternative queries","Minimum amount of alternative queries" +"The minimum amount of alternative queries that must be generated for a given original user search for enabling the storage of the results in the cache component. Set this to 1, for instance, to only cache the results when there is at least one alternative query generated by the thesaurus rules. Defaults to 0: cache storage is used even when there is no alternative queries through the thesaurus.","The minimum amount of alternative queries that must be generated for a given original user search for enabling the storage of the results in the cache component. Set this to 1, for instance, to only cache the results when there is at least one alternative query generated by the thesaurus rules. Defaults to 0: cache storage is used even when there is no alternative queries through the thesaurus." diff --git a/src/module-elasticsuite-thesaurus/i18n/fr_FR.csv b/src/module-elasticsuite-thesaurus/i18n/fr_FR.csv index 13ef87326..a85b69eae 100644 --- a/src/module-elasticsuite-thesaurus/i18n/fr_FR.csv +++ b/src/module-elasticsuite-thesaurus/i18n/fr_FR.csv @@ -68,3 +68,9 @@ Unselect Visible,Désectionner visibles "Total of %1 thesaurus were disabled.","Un total de %1 thesaurus ont été désactivés." "Please select thesaurus to enable.","Veuillez sélectionner un thésaurus à activer." "Total of %1 thesaurus were enabled.","Un total de %1 thesaurus ont été activés." +"Thesaurus (global settings)","Thésaurus (paramètres globaux)" +"Cache settings","Paramètres de cache" +"Always cache thesaurus application results","Toujours mettre en cache l'application du thésaurus" +"Set this to ""No"" to conditionally cache the results of user search query rewriting by active thesaurus rules. This can be used in peak trafic times to reduce the impact on your cache component with a trade-off of an increased volume of analysis requests sent to Elasticsearch/Opensearch and increased CPU usage.","Réglez ce paramètre à ""Non"" pour mettre en cache de façon conditionnelle les résultats de la réécriture d'une recherche utilisateur par les règles de thésaurus actives. Ceci peut être utilisé en période de fort traffic pour réduire l'impact sur votre composant de cache, avec en contrepartie une augmentation du nombre de requêtes d'analyse envoyées à Elasticsearch/OpenSearch et une utilisation accrue du CPU." +"Minimum amount of alternative queries","Nombre minimal de requêtes alternatives" +"The minimum amount of alternative queries that must be generated for a given original user search for enabling the storage of the results in the cache component. Set this to 1, for instance, to only cache the results when there is at least one alternative query generated by the thesaurus rules. Defaults to 0: cache storage is used even when there is no alternative queries through the thesaurus.","Le nombre minimal de requêtes alternatives devant être générées pour une requête utilisateur donnée pour activer le stockage des résultats dans le composant de cache. Réglez ce paramètre à 1, par exemple, pour ne mettre en cache que lorsqu'au moins une requête alternative est générée par les règles de thésaurus. La valeur par défaut est 0: la mise en cache est effectuée même si le thésaurus ne produit aucune requête de recherche alternative".