diff --git a/.github/workflows/pre-release-tests.yml b/.github/workflows/pre-release-tests.yml index 299a80af..dc0d43bf 100644 --- a/.github/workflows/pre-release-tests.yml +++ b/.github/workflows/pre-release-tests.yml @@ -39,39 +39,30 @@ jobs: MEILI_NO_ANALYTICS: true strategy: matrix: - php-version: ['7.4', '8.1', '8.2', '8.3', '8.4'] - sf-version: ['5.4', '6.4', '7.0', '7.1', '7.2', '7.3'] + php-version: [ '8.1', '8.2', '8.3', '8.4' ] + sf-version: ['6.4', '7.0', '7.1', '7.2', '7.3' ] + dependencies: [ 'default' ] exclude: - - php-version: '7.4' - sf-version: '6.4' - - php-version: '7.4' - sf-version: '7.0' - - php-version: '7.4' - sf-version: '7.1' - - php-version: '7.4' - sf-version: '7.3' - - php-version: '8.1' - sf-version: '5.4' - php-version: '8.1' sf-version: '7.0' - php-version: '8.1' sf-version: '7.1' - php-version: '8.1' - sf-version: '7.3' - - php-version: '8.2' - sf-version: '5.4' - - php-version: '8.3' - sf-version: '5.4' - - php-version: '8.4' - sf-version: '5.4' - - php-version: '7.4' - sf-version: '7.2' - - php-version: '8.0' sf-version: '7.2' - php-version: '8.1' - sf-version: '7.2' + sf-version: '7.3' + include: + - php-version: '8.4' + sf-version: '6.4' + dependencies: 'lowest' + - php-version: '8.4' + sf-version: '6.4' + dependencies: 'highest' + - php-version: '8.4' + sf-version: '7.3' + dependencies: 'highest' - name: integration-tests-against-rc (PHP ${{ matrix.php-version }}) (Symfony ${{ matrix.sf-version }}.*) + name: integration-tests-against-rc (PHP ${{ matrix.php-version }}) (Symfony ${{ matrix.sf-version }}.*)${{ matrix.dependencies != 'default' && format(' ({0})', matrix.dependencies) || '' }} steps: - name: Checkout code uses: actions/checkout@v6 @@ -85,16 +76,13 @@ jobs: - name: Validate composer.json and composer.lock run: composer validate - - name: Remove doctrine/annotations - if: matrix.php-version != '7.4' - run: sed -i '/doctrine\/annotations/d' composer.json - - name: Install dependencies uses: ramsey/composer-install@v3 env: SYMFONY_REQUIRE: ${{ matrix.sf-version }}.* with: - dependency-versions: 'highest' + composer-options: --no-interaction --no-progress --prefer-dist --no-security-blocking + dependency-versions: "${{ matrix.dependencies }}" - name: Run test suite run: composer test:unit -- --coverage-clover coverage.xml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7afa495f..e9031502 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,22 +27,10 @@ jobs: MEILI_NO_ANALYTICS: true strategy: matrix: - php-version: [ '7.4', '8.1', '8.2', '8.3', '8.4' ] - sf-version: [ '5.4', '6.4', '7.0', '7.1', '7.2', '7.3' ] + php-version: [ '8.1', '8.2', '8.3', '8.4' ] + sf-version: ['6.4', '7.0', '7.1', '7.2', '7.3' ] dependencies: [ 'default' ] exclude: - - php-version: '7.4' - sf-version: '6.4' - - php-version: '7.4' - sf-version: '7.0' - - php-version: '7.4' - sf-version: '7.1' - - php-version: '7.4' - sf-version: '7.2' - - php-version: '7.4' - sf-version: '7.3' - - php-version: '8.1' - sf-version: '5.4' - php-version: '8.1' sf-version: '7.0' - php-version: '8.1' @@ -51,19 +39,7 @@ jobs: sf-version: '7.2' - php-version: '8.1' sf-version: '7.3' - - php-version: '8.2' - sf-version: '5.4' - - php-version: '8.3' - sf-version: '5.4' - - php-version: '8.4' - sf-version: '5.4' include: - - php-version: '7.4' - sf-version: '5.4' - dependencies: 'lowest' - - php-version: '7.4' - sf-version: '5.4' - dependencies: 'highest' - php-version: '8.4' sf-version: '6.4' dependencies: 'lowest' @@ -88,10 +64,6 @@ jobs: - name: Validate composer.json run: composer validate - - name: Remove doctrine/annotations - if: matrix.php-version != '7.4' - run: sed -i '/doctrine\/annotations/d' composer.json - - name: Install dependencies uses: ramsey/composer-install@v3 env: @@ -126,7 +98,7 @@ jobs: - name: Install dependencies uses: ramsey/composer-install@v3 env: - SYMFONY_REQUIRE: 7.2.* + SYMFONY_REQUIRE: 7.4.* with: composer-options: '--no-progress --quiet' dependency-versions: 'highest' diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 1f7ae662..5688d059 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -12,6 +12,7 @@ ->setUnsupportedPhpVersionAllowed(true) ->setRiskyAllowed(true) ->setFinder($finder) + ->setUnsupportedPhpVersionAllowed(true) ->setRules([ '@Symfony' => true, '@PHP8x0Migration:risky' => true, diff --git a/composer.json b/composer.json index 0e065158..1591a145 100644 --- a/composer.json +++ b/composer.json @@ -18,40 +18,38 @@ } ], "require": { - "php": "^7.4|^8.0", + "php": "^8.1", "ext-json": "*", - "doctrine/doctrine-bundle": "^2.10 || ^3.0", "meilisearch/meilisearch-php": "^1.16", - "symfony/config": "^5.4 || ^6.0 || ^7.0", - "symfony/dependency-injection": "^5.4.17 || ^6.0 || ^7.0", - "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0", - "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0", - "symfony/polyfill-php80": "^1.27", - "symfony/property-access": "^5.4 || ^6.0 || ^7.0", - "symfony/serializer": "^5.4 || ^6.0 || ^7.0" + "symfony/config": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/event-dispatcher": "^6.4 || ^7.0", + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/property-access": "^6.4 || ^7.0", + "symfony/serializer": "^6.4 || ^7.0" }, "require-dev": { - "doctrine/annotations": "^2.0.0", + "doctrine/doctrine-bundle": "^2.10 || ^3.0", "doctrine/orm": "^2.12 || ^3.0", "matthiasnoback/symfony-config-test": "^4.3 || ^5.2", "matthiasnoback/symfony-dependency-injection-test": "^4.3 || ^5.1", "nikic/php-parser": "^5.6.2", "nyholm/psr7": "^1.8.2", - "php-cs-fixer/shim": "^3.88.2", + "php-cs-fixer/shim": "^3.90.0", "phpmd/phpmd": "^2.15", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.31", - "phpstan/phpstan-doctrine": "^2.0.10", - "phpstan/phpstan-phpunit": "^2.0.7", + "phpstan/phpstan": "^2.1.32", + "phpstan/phpstan-doctrine": "^2.0.11", + "phpstan/phpstan-phpunit": "^2.0.8", "phpstan/phpstan-symfony": "^2.0.8", "phpunit/php-code-coverage": "^9.2.32", - "symfony/doctrine-bridge": "^5.4.19 || ^6.0.7 || ^7.0", - "symfony/filesystem": "^5.4 || ^6.0 || ^7.0", - "symfony/framework-bundle": "^5.4.17 || ^6.0 || ^7.0", - "symfony/http-client": "^5.4.47 || ^6.4.15 || ^7.1.8", + "symfony/doctrine-bridge": "^6.4 || ^7.0", + "symfony/filesystem": "^6.4 || ^7.0", + "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/http-client": "^6.4.15 || ^7.1.8", "symfony/phpunit-bridge": "^7.3", - "symfony/yaml": "^5.4 || ^6.0 || ^7.0", - "symfony/var-exporter": "^5.4 || ^6.0 || ^7.0" + "symfony/yaml": "^6.4 || ^7.0", + "symfony/var-exporter": "^6.4 || ^7.0" }, "autoload": { "psr-4": { diff --git a/config/services.php b/config/services.php index 01de8b2a..f76bfd03 100644 --- a/config/services.php +++ b/config/services.php @@ -7,9 +7,13 @@ use Meilisearch\Bundle\Command\MeilisearchDeleteCommand; use Meilisearch\Bundle\Command\MeilisearchImportCommand; use Meilisearch\Bundle\Command\MeilisearchUpdateSettingsCommand; +use Meilisearch\Bundle\DataProvider\DataProviderRegistry; +use Meilisearch\Bundle\DataProvider\DataProviderRegistryInterface; use Meilisearch\Bundle\Engine; use Meilisearch\Bundle\EventListener\DoctrineEventSubscriber; +use Meilisearch\Bundle\SearchManagerInterface; use Meilisearch\Bundle\SearchService; +use Meilisearch\Bundle\Services\MeilisearchManager; use Meilisearch\Bundle\Services\MeilisearchService; use Meilisearch\Bundle\Services\SettingsUpdater; use Meilisearch\Bundle\Services\UnixTimestampNormalizer; @@ -30,15 +34,18 @@ service('meilisearch.engine'), abstract_arg('configuration'), service('property_accessor'), + service('meilisearch.manager'), ]); $services->alias('search.service', 'meilisearch.service') ->public() ->deprecate('meilisearch/search-bundle', '0.14', 'The "%alias_id%" service alias is deprecated. Use "meilisearch.service" instead.'); + $services->alias(SearchService::class, 'meilisearch.service'); + $services->set('meilisearch.search_indexer_subscriber', DoctrineEventSubscriber::class) ->public() - ->args([service('meilisearch.service')]); + ->args([service('meilisearch.manager')]); $services->alias('search.search_indexer_subscriber', 'meilisearch.search_indexer_subscriber') ->deprecate('meilisearch/search-bundle', '0.14', 'The "%alias_id%" service alias is deprecated. Use "meilisearch.search_indexer_subscriber" instead.'); @@ -62,11 +69,19 @@ $services->alias(Client::class, 'meilisearch.client') ->public(); - $services->alias(SearchService::class, 'meilisearch.service'); + $services->set('meilisearch.manager', MeilisearchManager::class) + ->args([ + abstract_arg('normalizer'), + service('meilisearch.engine'), + service('property_accessor'), + service('meilisearch.data_provider.registry'), + abstract_arg('configuration'), + ]); + $services->alias(SearchManagerInterface::class, 'meilisearch.manager'); $services->set('meilisearch.settings_updater', SettingsUpdater::class) ->args([ - service('meilisearch.service'), + service('meilisearch.manager'), service('meilisearch.client'), service('event_dispatcher'), ]); @@ -74,12 +89,12 @@ $services->alias(SettingsUpdater::class, 'meilisearch.settings_updater'); $services->set(MeilisearchClearCommand::class) - ->args([service('meilisearch.service')]) + ->args([service('meilisearch.manager')]) ->tag('console.command', ['command' => 'meilisearch:clear|meili:clear', 'description' => 'Clear the index documents']); $services->set(MeilisearchCreateCommand::class) ->args([ - service('meilisearch.service'), + service('meilisearch.manager'), service('meilisearch.client'), service('meilisearch.settings_updater'), service('event_dispatcher'), @@ -87,22 +102,22 @@ ->tag('console.command', ['command' => 'meilisearch:create|meili:create', 'description' => 'Create indexes']); $services->set(MeilisearchDeleteCommand::class) - ->args([service('meilisearch.service')]) + ->args([service('meilisearch.manager')]) ->tag('console.command', ['command' => 'meilisearch:delete|meili:delete', 'description' => 'Delete the indexes']); $services->set(MeilisearchImportCommand::class) ->args([ - service('meilisearch.service'), - service('doctrine'), + service('meilisearch.manager'), service('meilisearch.client'), service('meilisearch.settings_updater'), service('event_dispatcher'), + service('meilisearch.data_provider.registry'), ]) ->tag('console.command', ['command' => 'meilisearch:import|meili:import', 'description' => 'Import given entity into search engine']); $services->set(MeilisearchUpdateSettingsCommand::class) ->args([ - service('meilisearch.service'), + service('meilisearch.manager'), service('meilisearch.settings_updater'), service('event_dispatcher'), ]) @@ -110,4 +125,11 @@ $services->set(UnixTimestampNormalizer::class) ->tag('serializer.normalizer'); + + $services->set('meilisearch.data_provider.registry', DataProviderRegistry::class) + ->args([ + abstract_arg('provider locator'), + abstract_arg('provider map'), + ]) + ->alias(DataProviderRegistryInterface::class, 'meilisearch.data_provider.registry'); }; diff --git a/phpstan-baseline.php b/phpstan-baseline.php new file mode 100644 index 00000000..7ff16f72 --- /dev/null +++ b/phpstan-baseline.php @@ -0,0 +1,23 @@ + '#^Call to static method getClass\\(\\) on an unknown class Doctrine\\\\Common\\\\Util\\\\ClassUtils\\.$#', + 'identifier' => 'class.notFound', + 'count' => 1, + 'path' => __DIR__ . '/src/Services/MeilisearchManager.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to static method getClass\\(\\) on an unknown class Doctrine\\\\Common\\\\Util\\\\ClassUtils\\.$#', + 'identifier' => 'class.notFound', + 'count' => 1, + 'path' => __DIR__ . '/src/Services/MeilisearchService.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Offset \'hits\' on array\\{hits\\: array\\, query\\: string, processingTimeMs\\: int, limit\\: int, offset\\: int, estimatedTotalHits\\: int, requestUid\\: non\\-empty\\-string, nbHits\\: int\\} in isset\\(\\) always exists and is not nullable\\.$#', + 'identifier' => 'isset.offset', + 'count' => 1, + 'path' => __DIR__ . '/src/Services/MeilisearchService.php', +]; + +return ['parameters' => ['ignoreErrors' => $ignoreErrors]]; diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 5c8c9244..80765e60 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,3 +1,6 @@ +includes: + - phpstan-baseline.php + parameters: bootstrapFiles: - vendor/bin/.phpunit/phpunit/vendor/autoload.php @@ -5,6 +8,3 @@ parameters: paths: - src - tests - ignoreErrors: - - '#Call to static method getClass\(\) on an unknown class Doctrine\\Common\\Util\\ClassUtils#' - - '#Parameter \#1 \$array of function array_unique expects an array of values castable to string, list\|Meilisearch\\Bundle\\Entity\\Aggregator> given#' diff --git a/src/Command/IndexCommand.php b/src/Command/IndexCommand.php index 8260b12e..59d0911d 100644 --- a/src/Command/IndexCommand.php +++ b/src/Command/IndexCommand.php @@ -5,7 +5,7 @@ namespace Meilisearch\Bundle\Command; use Meilisearch\Bundle\Collection; -use Meilisearch\Bundle\SearchService; +use Meilisearch\Bundle\SearchManagerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -14,21 +14,18 @@ abstract class IndexCommand extends Command { protected const DEFAULT_RESPONSE_TIMEOUT = 5000; - protected SearchService $searchService; - private string $prefix; - public function __construct(SearchService $searchService) + public function __construct(protected SearchManagerInterface $searchManager) { - $this->searchService = $searchService; - $this->prefix = $this->searchService->getConfiguration()->get('prefix'); + $this->prefix = $this->searchManager->getConfiguration()->get('prefix'); parent::__construct(); } protected function getEntitiesFromArgs(InputInterface $input, OutputInterface $output): Collection { - $indices = new Collection($this->searchService->getConfiguration()->get('indices')); + $indices = new Collection($this->searchManager->getConfiguration()->get('indices')); $indexNames = new Collection(); if ($indexList = $input->getOption('indices')) { diff --git a/src/Command/MeilisearchClearCommand.php b/src/Command/MeilisearchClearCommand.php index 6de03ccf..af38e0a4 100644 --- a/src/Command/MeilisearchClearCommand.php +++ b/src/Command/MeilisearchClearCommand.php @@ -27,7 +27,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $indexName = $index['prefixed_name']; $className = $index['class']; $msg = "Cleared $indexName index of $className"; - $array = $this->searchService->clear($className); + $array = $this->searchManager->clear($className); if ('failed' === $array['status']) { $msg = "Index $indexName couldn\'t be cleared"; diff --git a/src/Command/MeilisearchCreateCommand.php b/src/Command/MeilisearchCreateCommand.php index 7058ab4d..31903f8c 100644 --- a/src/Command/MeilisearchCreateCommand.php +++ b/src/Command/MeilisearchCreateCommand.php @@ -7,7 +7,7 @@ use Meilisearch\Bundle\Collection; use Meilisearch\Bundle\EventListener\ConsoleOutputSubscriber; use Meilisearch\Bundle\Model\Aggregator; -use Meilisearch\Bundle\SearchService; +use Meilisearch\Bundle\SearchManagerInterface; use Meilisearch\Bundle\Services\SettingsUpdater; use Meilisearch\Client; use Symfony\Component\Console\Attribute\AsCommand; @@ -20,17 +20,13 @@ #[AsCommand(name: 'meilisearch:create', description: 'Create indexes', aliases: ['meili:create'])] final class MeilisearchCreateCommand extends IndexCommand { - private Client $searchClient; - private SettingsUpdater $settingsUpdater; - private EventDispatcherInterface $eventDispatcher; - - public function __construct(SearchService $searchService, Client $searchClient, SettingsUpdater $settingsUpdater, EventDispatcherInterface $eventDispatcher) - { - parent::__construct($searchService); - - $this->searchClient = $searchClient; - $this->settingsUpdater = $settingsUpdater; - $this->eventDispatcher = $eventDispatcher; + public function __construct( + SearchManagerInterface $searchManager, + private readonly Client $searchClient, + private readonly SettingsUpdater $settingsUpdater, + private readonly EventDispatcherInterface $eventDispatcher, + ) { + parent::__construct($searchManager); } protected function configure(): void @@ -67,7 +63,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int foreach ($entitiesToIndex as $index) { $entityClassName = $index['class']; - if (!$this->searchService->isSearchable($entityClassName)) { + if (!$this->searchManager->isSearchable($entityClassName)) { continue; } diff --git a/src/Command/MeilisearchDeleteCommand.php b/src/Command/MeilisearchDeleteCommand.php index bae54e53..87e67c5e 100644 --- a/src/Command/MeilisearchDeleteCommand.php +++ b/src/Command/MeilisearchDeleteCommand.php @@ -29,7 +29,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $indexName = $index['prefixed_name']; try { - $this->searchService->deleteByIndexName($indexName); + $this->searchManager->deleteByIndexName($indexName); } catch (ApiException $e) { $output->writeln('Cannot delete '.$indexName.': '.$e->getMessage()); diff --git a/src/Command/MeilisearchImportCommand.php b/src/Command/MeilisearchImportCommand.php index 37da172e..fa9bc675 100644 --- a/src/Command/MeilisearchImportCommand.php +++ b/src/Command/MeilisearchImportCommand.php @@ -4,12 +4,12 @@ namespace Meilisearch\Bundle\Command; -use Doctrine\Persistence\ManagerRegistry; use Meilisearch\Bundle\Collection; +use Meilisearch\Bundle\DataProvider\DataProviderRegistryInterface; use Meilisearch\Bundle\EventListener\ConsoleOutputSubscriber; use Meilisearch\Bundle\Exception\TaskException; use Meilisearch\Bundle\Model\Aggregator; -use Meilisearch\Bundle\SearchService; +use Meilisearch\Bundle\SearchManagerInterface; use Meilisearch\Bundle\Services\SettingsUpdater; use Meilisearch\Client; use Meilisearch\Exceptions\TimeOutException; @@ -25,19 +25,14 @@ final class MeilisearchImportCommand extends IndexCommand { private const TEMP_INDEX_PREFIX = '_tmp_'; - private Client $searchClient; - private ManagerRegistry $managerRegistry; - private SettingsUpdater $settingsUpdater; - private EventDispatcherInterface $eventDispatcher; - - public function __construct(SearchService $searchService, ManagerRegistry $managerRegistry, Client $searchClient, SettingsUpdater $settingsUpdater, EventDispatcherInterface $eventDispatcher) - { - parent::__construct($searchService); - - $this->managerRegistry = $managerRegistry; - $this->searchClient = $searchClient; - $this->settingsUpdater = $settingsUpdater; - $this->eventDispatcher = $eventDispatcher; + public function __construct( + SearchManagerInterface $searchManager, + private readonly Client $searchClient, + private readonly SettingsUpdater $settingsUpdater, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly DataProviderRegistryInterface $dataProviderRegistry, + ) { + parent::__construct($searchManager); } protected function configure(): void @@ -81,8 +76,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $indexes = $this->getEntitiesFromArgs($input, $output); $entitiesToIndex = $this->entitiesToIndex($indexes); - $config = $this->searchService->getConfiguration(); + $config = $this->searchManager->getConfiguration(); + $updateSettings = $input->getOption('update-settings'); + $batchSize = $input->getOption('batch-size') ?? ''; + $batchSize = \is_string($batchSize) && ctype_digit($batchSize) ? (int) $batchSize : $config->get('batchSize'); $swapIndices = $input->getOption('swap-indices'); + $responseTimeout = ((int) $input->getOption('response-timeout')) ?: self::DEFAULT_RESPONSE_TIMEOUT; $initialPrefix = $config['prefix'] ?? ''; $prefix = null; @@ -91,27 +90,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $config['prefix'] = $prefix.($config['prefix'] ?? ''); } - $updateSettings = $input->getOption('update-settings'); - $batchSize = $input->getOption('batch-size') ?? ''; - $batchSize = ctype_digit($batchSize) ? (int) $batchSize : $config->get('batchSize'); - $responseTimeout = ((int) $input->getOption('response-timeout')) ?: self::DEFAULT_RESPONSE_TIMEOUT; - /** @var array $index */ foreach ($entitiesToIndex as $index) { $entityClassName = $index['class']; - if (!$this->searchService->isSearchable($entityClassName)) { + if (!$this->searchManager->isSearchable($entityClassName)) { continue; } $totalIndexed = 0; - $manager = $this->managerRegistry->getManagerForClass($entityClassName); - $repository = $manager->getRepository($entityClassName); - $classMetadata = $manager->getClassMetadata($entityClassName); - $entityIdentifiers = $classMetadata->getIdentifierFieldNames(); - $sortByAttrs = array_combine($entityIdentifiers, array_fill(0, \count($entityIdentifiers), 'ASC')); - $output->writeln('Importing for index '.$entityClassName.''); if ($updateSettings) { @@ -130,22 +118,26 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); } + $dataProvider = $this->dataProviderRegistry->getDataProvider($index['name'], $index['class']); + do { - $entities = $repository->findBy( - [], - $sortByAttrs, - $batchSize, - $batchSize * $page - ); + $objects = $dataProvider->provide($batchSize, $batchSize * $page); + + if ([] === $objects) { + $dataProvider->cleanup(); + + break; + } + + $responses = $this->formatIndexingResponse($this->searchManager->index($objects), $responseTimeout); + $totalIndexed += \count($objects); - $responses = $this->formatIndexingResponse($this->searchService->index($manager, $entities), $responseTimeout); - $totalIndexed += \count($entities); foreach ($responses as $indexName => $numberOfRecords) { $output->writeln( \sprintf( 'Indexed a batch of %d / %d %s entities into %s index (%d indexed since start)', $numberOfRecords, - \count($entities), + \count($objects), $entityClassName, ''.$indexName.'', $totalIndexed, @@ -153,12 +145,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); } - $manager->clear(); + $dataProvider->cleanup(); ++$page; - } while (\count($entities) >= $batchSize); - - $manager->clear(); + } while (\count($objects) >= $batchSize); } if ($swapIndices) { @@ -181,13 +171,9 @@ private function formatIndexingResponse(array $batch, int $responseTimeout): arr foreach ($batch as $chunk) { foreach ($chunk as $indexName => $apiResponse) { - if (!\array_key_exists($indexName, $formattedResponse)) { - $formattedResponse[$indexName] = 0; - } - + $formattedResponse[$indexName] ??= 0; $indexInstance = $this->searchClient->index($indexName); - // Get task information using uid $indexInstance->waitForTask($apiResponse['taskUid'], $responseTimeout); $task = $indexInstance->getTask($apiResponse['taskUid']); @@ -248,7 +234,7 @@ private function swapIndices(Collection $indexes, string $prefix, OutputInterfac // delete temp indexes foreach ($indexPairs as $pair) { - $this->searchService->deleteByIndexName($pair[0]); + $this->searchManager->deleteByIndexName($pair[0]); $output->writeln('Deleted '.$pair[0].''); } } diff --git a/src/Command/MeilisearchUpdateSettingsCommand.php b/src/Command/MeilisearchUpdateSettingsCommand.php index a91fdcf6..7260f531 100644 --- a/src/Command/MeilisearchUpdateSettingsCommand.php +++ b/src/Command/MeilisearchUpdateSettingsCommand.php @@ -7,7 +7,7 @@ use Meilisearch\Bundle\Collection; use Meilisearch\Bundle\EventListener\ConsoleOutputSubscriber; use Meilisearch\Bundle\Model\Aggregator; -use Meilisearch\Bundle\SearchService; +use Meilisearch\Bundle\SearchManagerInterface; use Meilisearch\Bundle\Services\SettingsUpdater; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; @@ -19,15 +19,12 @@ #[AsCommand(name: 'meilisearch:update-settings', description: 'Push settings to meilisearch')] final class MeilisearchUpdateSettingsCommand extends IndexCommand { - private SettingsUpdater $settingsUpdater; - private EventDispatcherInterface $eventDispatcher; - - public function __construct(SearchService $searchService, SettingsUpdater $settingsUpdater, EventDispatcherInterface $eventDispatcher) - { - parent::__construct($searchService); - - $this->settingsUpdater = $settingsUpdater; - $this->eventDispatcher = $eventDispatcher; + public function __construct( + SearchManagerInterface $searchManager, + private readonly SettingsUpdater $settingsUpdater, + private readonly EventDispatcherInterface $eventDispatcher, + ) { + parent::__construct($searchManager); } protected function configure(): void @@ -56,7 +53,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int foreach ($entitiesToIndex as $index) { $entityClassName = $index['class']; - if (!$this->searchService->isSearchable($entityClassName)) { + if (!$this->searchManager->isSearchable($entityClassName)) { continue; } diff --git a/src/DataProvider/DataProviderInterface.php b/src/DataProvider/DataProviderInterface.php new file mode 100644 index 00000000..03d52446 --- /dev/null +++ b/src/DataProvider/DataProviderInterface.php @@ -0,0 +1,35 @@ + + */ + public function provide(int $limit, int $offset): array; + + /** + * @param array $identifiers + * + * @return iterable + */ + public function loadByIdentifiers(array $identifiers): iterable; + + /** + * Returns the identifier of this object as an array with a field name as a key. + * + * @return non-empty-array + */ + public function getIdentifierValues(object $object): array; + + public function cleanup(): void; +} diff --git a/src/DataProvider/DataProviderRegistry.php b/src/DataProvider/DataProviderRegistry.php new file mode 100644 index 00000000..1ba455d0 --- /dev/null +++ b/src/DataProvider/DataProviderRegistry.php @@ -0,0 +1,37 @@ +> $dataProvidersMap + */ + public function __construct( + private readonly ContainerInterface $dataProviders, + private readonly array $dataProvidersMap, + ) { + } + + public function getDataProvider(string $indexName, string $className): DataProviderInterface + { + if (isset($this->dataProvidersMap[$indexName][$className])) { + return $this->dataProviders->get($this->dataProvidersMap[$indexName][$className]); + } + + if (isset($this->dataProvidersMap[$indexName])) { + foreach ($this->dataProvidersMap[$indexName] as $registeredClass => $locatorKey) { + if (is_a($className, $registeredClass, true)) { + return $this->dataProviders->get($locatorKey); + } + } + } + + throw new DataProviderNotFoundException($indexName, $className); + } +} diff --git a/src/DataProvider/DataProviderRegistryInterface.php b/src/DataProvider/DataProviderRegistryInterface.php new file mode 100644 index 00000000..06bfe3a0 --- /dev/null +++ b/src/DataProvider/DataProviderRegistryInterface.php @@ -0,0 +1,22 @@ + $className + * + * @return DataProviderInterface + * + * @throws DataProviderNotFoundException + */ + public function getDataProvider(string $indexName, string $className): DataProviderInterface; +} diff --git a/src/DataProvider/OrmEntityProvider.php b/src/DataProvider/OrmEntityProvider.php new file mode 100644 index 00000000..131836e1 --- /dev/null +++ b/src/DataProvider/OrmEntityProvider.php @@ -0,0 +1,58 @@ +managerRegistry->getManagerForClass($this->className); + $repository = $manager->getRepository($this->className); + $classMetadata = $manager->getClassMetadata($this->className); + $entityIdentifiers = $classMetadata->getIdentifierFieldNames(); + $sortByAttrs = array_combine($entityIdentifiers, array_fill(0, \count($entityIdentifiers), 'ASC')); + + return $repository->findBy([], $sortByAttrs, $limit, $offset); + } + + public function loadByIdentifiers(array $identifiers): array + { + $manager = $this->managerRegistry->getManagerForClass($this->className); + $repository = $manager->getRepository($this->className); + $classMetadata = $manager->getClassMetadata($this->className); + $identifierFieldNames = $classMetadata->getIdentifierFieldNames(); + + // For single-field identifiers, use the actual field name + if (1 === \count($identifierFieldNames)) { + return $repository->findBy([$identifierFieldNames[0] => $identifiers]); + } + + throw new LogicException('Composite identifiers are not yet supported.'); + } + + public function getIdentifierValues(object $object): array + { + $manager = $this->managerRegistry->getManagerForClass(\get_class($object)); + + return $manager->getClassMetadata(\get_class($object))->getIdentifierValues($object); + } + + public function cleanup(): void + { + $this->managerRegistry->getManagerForClass($this->className)->clear(); + } +} diff --git a/src/DependencyInjection/Compiler/DataProviderPass.php b/src/DependencyInjection/Compiler/DataProviderPass.php new file mode 100644 index 00000000..4083d4df --- /dev/null +++ b/src/DependencyInjection/Compiler/DataProviderPass.php @@ -0,0 +1,40 @@ +hasDefinition('meilisearch.data_provider.registry')) { + return; + } + + $definition = $container->getDefinition('meilisearch.data_provider.registry'); + + $locatorServices = []; + $providersMap = []; + + foreach ($container->findTaggedServiceIds('meilisearch.data_provider') as $id => $tags) { + foreach ($tags as $attributes) { + $index = $attributes['index']; + $class = $attributes['class']; + + $locatorKey = $index.'|'.$class; + + $locatorServices[$locatorKey] = new Reference($id); + $providersMap[$index][$class] = $locatorKey; + } + } + + $definition->setArgument('$dataProviders', ServiceLocatorTagPass::register($container, $locatorServices)); + $definition->setArgument('$dataProvidersMap', $providersMap); + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 97dd0f84..d1b1c8fb 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -4,7 +4,7 @@ namespace Meilisearch\Bundle\DependencyInjection; -use Meilisearch\Bundle\Searchable; +use Meilisearch\Bundle\SearchableObject; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; @@ -50,13 +50,22 @@ public function getConfigTreeBuilder(): TreeBuilder ->isRequired() ->cannotBeEmpty() ->end() + ->enumNode('type') + ->defaultValue('orm') + ->values(['orm', 'custom']) + ->end() + ->scalarNode('primary_key') + ->defaultValue('objectID') + ->cannotBeEmpty() + ->end() + ->scalarNode('data_provider')->defaultNull()->end() ->booleanNode('enable_serializer_groups') - ->info('When set to true, it will call normalize method with an extra groups parameter "groups" => [Searchable::NORMALIZATION_GROUP]') + ->info('When set to true, it will call normalize method with an extra groups parameter "groups" => [SearchableObject::NORMALIZATION_GROUP]') ->defaultFalse() ->end() ->arrayNode('serializer_groups') - ->info('When setting a different value, normalization will be called with it instead of "Searchable::NORMALIZATION_GROUP".') - ->defaultValue([Searchable::NORMALIZATION_GROUP]) + ->info('When setting a different value, normalization will be called with it instead of "SearchableObject::NORMALIZATION_GROUP".') + ->defaultValue([SearchableObject::NORMALIZATION_GROUP]) ->scalarPrototype()->end() ->end() ->scalarNode('index_if') diff --git a/src/DependencyInjection/MeilisearchExtension.php b/src/DependencyInjection/MeilisearchExtension.php index 25166844..6ff7b1bf 100644 --- a/src/DependencyInjection/MeilisearchExtension.php +++ b/src/DependencyInjection/MeilisearchExtension.php @@ -4,11 +4,14 @@ namespace Meilisearch\Bundle\DependencyInjection; +use Meilisearch\Bundle\DataProvider\OrmEntityProvider; use Meilisearch\Bundle\MeilisearchBundle; +use Meilisearch\Bundle\Model\Aggregator; use Meilisearch\Bundle\Services\UnixTimestampNormalizer; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Loader; use Symfony\Component\DependencyInjection\Reference; @@ -28,14 +31,16 @@ public function load(array $configs, ContainerBuilder $container): void $config['prefix'] = $container->getParameter('kernel.environment').'_'; } + $container->setParameter('meili_url', $config['url'] ?? null); + $container->setParameter('meili_api_key', $config['api_key'] ?? null); + $container->setParameter('meili_symfony_version', MeilisearchBundle::qualifiedVersion()); + foreach ($config['indices'] as $index => $indice) { $config['indices'][$index]['prefixed_name'] = $config['prefix'].$indice['name']; $config['indices'][$index]['settings'] = $this->findReferences($config['indices'][$index]['settings']); } - $container->setParameter('meili_url', $config['url'] ?? null); - $container->setParameter('meili_api_key', $config['api_key'] ?? null); - $container->setParameter('meili_symfony_version', MeilisearchBundle::qualifiedVersion()); + $this->registerDataProviders($container, $config); if (\count($doctrineEvents = $config['doctrineSubscribedEvents']) > 0) { $subscriber = $container->getDefinition('meilisearch.search_indexer_subscriber'); @@ -58,15 +63,19 @@ public function load(array $configs, ContainerBuilder $container): void ->replaceArgument(0, new Reference($config['serializer'])) ->replaceArgument(2, $config); + $container->findDefinition('meilisearch.manager') + ->replaceArgument(0, new Reference($config['serializer'])) + ->replaceArgument(4, $config); + if (Kernel::VERSION_ID >= 70100) { $container->removeDefinition(UnixTimestampNormalizer::class); } } /** - * @param array $settings + * @param array $settings * - * @return array + * @return array */ private function findReferences(array $settings): array { @@ -80,4 +89,49 @@ private function findReferences(array $settings): array return $settings; } + + private function registerDataProviders(ContainerBuilder $container, array $config): void + { + foreach ($config['indices'] as $indice) { + $indexName = $indice['name']; + $class = $indice['class']; + + if (null !== $indice['data_provider']) { + if ($container->hasDefinition($indice['data_provider'])) { + $container + ->findDefinition($indice['data_provider']) + ->addTag('meilisearch.data_provider', [ + 'index' => $indexName, + 'class' => $class, + ]); + } + + continue; + } + + if ('orm' === $indice['type']) { + if (is_subclass_of($class, Aggregator::class)) { + foreach ($class::getEntities() as $aggregatedClass) { + $this->registerOrmProvider($container, $indexName, $aggregatedClass); + } + } else { + $this->registerOrmProvider($container, $indexName, $class); + } + } + } + } + + private function registerOrmProvider(ContainerBuilder $container, string $indexName, string $class): void + { + $definitionId = \sprintf('meilisearch.data_provider.%s_%s', $indexName, hash('xxh32', $class)); + + $definition = new Definition(OrmEntityProvider::class, [new Reference('doctrine'), $class]); + + $definition->addTag('meilisearch.data_provider', [ + 'index' => $indexName, + 'class' => $class, + ]); + + $container->setDefinition($definitionId, $definition); + } } diff --git a/src/Engine.php b/src/Engine.php index 937c6962..ee4a4dd4 100644 --- a/src/Engine.php +++ b/src/Engine.php @@ -7,13 +7,44 @@ use Meilisearch\Client; use Meilisearch\Exceptions\ApiException; +/** + * @phpstan-type TaskStatus 'canceled'|'enqueued'|'failed'|'succeeded'|'processing'|non-empty-string + * @phpstan-type SearchResponse array{ + * hits: array, + * query: string, + * processingTimeMs: int, + * limit: int, + * offset: int, + * estimatedTotalHits: int, + * requestUid: non-empty-string, + * nbHits: int + * } + * @phpstan-type IndexDeletionTask array{ + * taskUid: int, + * indexUid: non-empty-string, + * status: TaskStatus, + * type: 'indexDeletion', + * enqueuedAt: non-empty-string + * } + * @phpstan-type DocumentDeletionTask array{ + * taskUid: int, + * indexUid: non-empty-string, + * status: TaskStatus, + * type: 'documentDeletion', + * enqueuedAt: non-empty-string + * } + * @phpstan-type DocumentAdditionOrUpdateTask array{ + * taskUid: int, + * indexUid: non-empty-string, + * status: TaskStatus, + * type: 'documentAdditionOrUpdate', + * enqueuedAt: non-empty-string + * } + */ final class Engine { - private Client $client; - - public function __construct(Client $client) + public function __construct(private readonly Client $client) { - $this->client = $client; } /** @@ -21,37 +52,38 @@ public function __construct(Client $client) * This method allows you to create records on your index by sending one or more objects. * Each object contains a set of attributes and values, which represents a full record on an index. * - * @param array|SearchableEntity $searchableEntities + * @param SearchableObject|array $searchableObjects + * + * @return array * * @throws ApiException */ - public function index($searchableEntities): array + public function index(SearchableObject|array $searchableObjects): array { - if ($searchableEntities instanceof SearchableEntity) { - $searchableEntities = [$searchableEntities]; + if ($searchableObjects instanceof SearchableObject) { + $searchableObjects = [$searchableObjects]; } $data = []; - foreach ($searchableEntities as $entity) { - $searchableArray = $entity->getSearchableArray(); + + foreach ($searchableObjects as $object) { + $searchableArray = $object->getSearchableArray(); + if ([] === $searchableArray) { continue; } - $indexUid = $entity->getIndexUid(); + $indexUid = $object->getIndexUid(); - if (!isset($data[$indexUid])) { - $data[$indexUid] = []; - } - - $data[$indexUid][] = $searchableArray + ['objectID' => $this->normalizeId($entity->getId())]; + $data[$indexUid] ??= ['primaryKey' => $object->getPrimaryKey(), 'documents' => []]; + $data[$indexUid]['documents'][] = $searchableArray + [$object->getPrimaryKey() => $this->normalizeId($object->getIdentifier())]; } $result = []; - foreach ($data as $indexUid => $objects) { + foreach ($data as $indexUid => $batch) { $result[$indexUid] = $this->client ->index($indexUid) - ->addDocuments($objects, 'objectID'); + ->addDocuments($batch['documents'], $batch['primaryKey']); } return $result; @@ -61,24 +93,23 @@ public function index($searchableEntities): array * Remove objects from an index using their object UIDs. * This method enables you to remove one or more objects from an index. * - * @param array|SearchableEntity $searchableEntities + * @param SearchableObject|array $searchableObjects + * + * @return array> */ - public function remove($searchableEntities): array + public function remove(SearchableObject|array $searchableObjects): array { - if ($searchableEntities instanceof SearchableEntity) { - $searchableEntities = [$searchableEntities]; + if ($searchableObjects instanceof SearchableObject) { + $searchableObjects = [$searchableObjects]; } $data = []; - foreach ($searchableEntities as $entity) { - $indexUid = $entity->getIndexUid(); + foreach ($searchableObjects as $object) { + $indexUid = $object->getIndexUid(); - if (!isset($data[$indexUid])) { - $data[$indexUid] = []; - } - - $data[$indexUid][] = $this->normalizeId($entity->getId()); + $data[$indexUid] ??= []; + $data[$indexUid][] = $this->normalizeId($object->getIdentifier()); } $result = []; @@ -99,17 +130,19 @@ public function remove($searchableEntities): array * This method enables you to delete an index’s contents (records). * Will fail if the index does not exist. * + * @return DocumentDeletionTask + * * @throws ApiException */ public function clear(string $indexUid): array { - $index = $this->client->index($indexUid); - - return $index->deleteAllDocuments(); + return $this->client->index($indexUid)->deleteAllDocuments(); } /** * Delete an index and its content. + * + * @return IndexDeletionTask */ public function delete(string $indexUid): array { @@ -118,6 +151,10 @@ public function delete(string $indexUid): array /** * Method used for querying an index. + * + * @param array $searchParams + * + * @return SearchResponse */ public function search(string $query, string $indexUid, array $searchParams): array { @@ -126,15 +163,17 @@ public function search(string $query, string $indexUid, array $searchParams): ar /** * Search the index and returns the number of results. + * + * @param array $searchParams */ public function count(string $query, string $indexName, array $searchParams): int { return $this->client->index($indexName)->search($query, $searchParams)->getHitsCount(); } - private function normalizeId($id) + private function normalizeId(\Stringable|string|int $id): string|int { - if (\is_object($id) && method_exists($id, '__toString')) { + if (\is_object($id)) { return (string) $id; } diff --git a/src/Event/SettingsUpdatedEvent.php b/src/Event/SettingsUpdatedEvent.php index 61070374..880dcfb6 100644 --- a/src/Event/SettingsUpdatedEvent.php +++ b/src/Event/SettingsUpdatedEvent.php @@ -8,31 +8,16 @@ final class SettingsUpdatedEvent extends Event { - /** - * @var class-string - */ - private string $class; - - /** - * @var non-empty-string - */ - private string $index; - - /** - * @var non-empty-string - */ - private string $setting; - /** * @param class-string $class * @param non-empty-string $index * @param non-empty-string $setting */ - public function __construct(string $class, string $index, string $setting) - { - $this->index = $index; - $this->class = $class; - $this->setting = $setting; + public function __construct( + private readonly string $class, + private readonly string $index, + private readonly string $setting, + ) { } /** diff --git a/src/EventListener/ConsoleOutputSubscriber.php b/src/EventListener/ConsoleOutputSubscriber.php index c497e90d..923a6610 100644 --- a/src/EventListener/ConsoleOutputSubscriber.php +++ b/src/EventListener/ConsoleOutputSubscriber.php @@ -10,16 +10,13 @@ final class ConsoleOutputSubscriber implements EventSubscriberInterface { - private OutputStyle $io; - - public function __construct(OutputStyle $io) + public function __construct(private readonly OutputStyle $output) { - $this->io = $io; } public function afterSettingsUpdate(SettingsUpdatedEvent $event): void { - $this->io->writeln('Setting "'.$event->getSetting().'" updated of "'.$event->getIndex().'".'); + $this->output->writeln('Setting "'.$event->getSetting().'" updated of "'.$event->getIndex().'".'); } public static function getSubscribedEvents(): array diff --git a/src/EventListener/DoctrineEventSubscriber.php b/src/EventListener/DoctrineEventSubscriber.php index 9f523fac..603be3ea 100644 --- a/src/EventListener/DoctrineEventSubscriber.php +++ b/src/EventListener/DoctrineEventSubscriber.php @@ -5,29 +5,26 @@ namespace Meilisearch\Bundle\EventListener; use Doctrine\Persistence\Event\LifecycleEventArgs; -use Meilisearch\Bundle\SearchService; +use Meilisearch\Bundle\SearchManagerInterface; final class DoctrineEventSubscriber { - private SearchService $searchService; - - public function __construct(SearchService $searchService) + public function __construct(private readonly SearchManagerInterface $searchManager) { - $this->searchService = $searchService; } public function postUpdate(LifecycleEventArgs $args): void { - $this->searchService->index($args->getObjectManager(), $args->getObject()); + $this->searchManager->index($args->getObject()); } public function postPersist(LifecycleEventArgs $args): void { - $this->searchService->index($args->getObjectManager(), $args->getObject()); + $this->searchManager->index($args->getObject()); } public function preRemove(LifecycleEventArgs $args): void { - $this->searchService->remove($args->getObjectManager(), $args->getObject()); + $this->searchManager->remove($args->getObject()); } } diff --git a/src/Exception/DataProviderNotFoundException.php b/src/Exception/DataProviderNotFoundException.php new file mode 100644 index 00000000..a1684cb6 --- /dev/null +++ b/src/Exception/DataProviderNotFoundException.php @@ -0,0 +1,13 @@ +addCompilerPass(new DataProviderPass()); + } + public function getPath(): string { return \dirname(__DIR__); diff --git a/src/Model/Aggregator.php b/src/Model/Aggregator.php index ddb42535..a5576eef 100644 --- a/src/Model/Aggregator.php +++ b/src/Model/Aggregator.php @@ -9,6 +9,9 @@ use Symfony\Component\Serializer\Normalizer\NormalizableInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +/** + * @phpstan-consistent-constructor + */ abstract class Aggregator implements NormalizableInterface { /** @@ -20,16 +23,17 @@ abstract class Aggregator implements NormalizableInterface /** * Holds a doctrine {@ORM\Entity} or {@ODM\Document} object. - * - * @var object */ - protected $entity; + protected object $entity; /** - * @param object $entity + * @param string $primaryKey defaults to `objectID` for backwards compatibility */ - public function __construct($entity, array $entityIdentifierValues) - { + public function __construct( + object $entity, + array $entityIdentifierValues, + protected readonly string $primaryKey = 'objectID', + ) { $this->entity = $entity; if (\count($entityIdentifierValues) > 1) { @@ -75,6 +79,6 @@ public static function getEntityClassFromObjectID(string $objectId): string public function normalize(NormalizerInterface $normalizer, ?string $format = null, array $context = []): array { - return array_merge(['objectID' => $this->objectID], $normalizer->normalize($this->entity, $format, $context)); + return array_merge([$this->primaryKey => $this->objectID], $normalizer->normalize($this->entity, $format, $context)); } } diff --git a/src/SearchManagerInterface.php b/src/SearchManagerInterface.php new file mode 100644 index 00000000..48872d6c --- /dev/null +++ b/src/SearchManagerInterface.php @@ -0,0 +1,107 @@ + $searchable + * + * @return list> + * + * @throws NotSearchableException + */ + public function index(object|array $searchable): array; + + /** + * @param object|array $searchable + * + * @return array> + * + * @throws NotSearchableException + */ + public function remove(object|array $searchable): array; + + /** + * @param class-string $className + * + * @return DocumentDeletionTask + * + * @throws NotSearchableException + */ + public function clear(string $className): array; + + /** + * @param non-empty-string $indexName + * + * @return IndexDeletionTask + */ + public function deleteByIndexName(string $indexName): array; + + /** + * @param class-string $className + * + * @return IndexDeletionTask + * + * @throws NotSearchableException + */ + public function delete(string $className): array; + + /** + * @template T of object + * + * @param class-string $className + * @param array $searchParams + * + * @return list + * + * @throws NotSearchableException + */ + public function search(string $className, string $query = '', array $searchParams = []): array; + + /** + * @param class-string $className + * @param array $searchParams + * + * @return SearchResponse + * + * @throws NotSearchableException + */ + public function rawSearch(string $className, string $query = '', array $searchParams = []): array; + + /** + * @param class-string $className + * @param array $searchParams + * + * @throws NotSearchableException + */ + public function count(string $className, string $query = '', array $searchParams = []): int; +} diff --git a/src/SearchService.php b/src/SearchService.php index 9fbdcfef..0c49fe75 100644 --- a/src/SearchService.php +++ b/src/SearchService.php @@ -6,6 +6,9 @@ use Doctrine\Persistence\ObjectManager; +/** + * @deprecated Since 0.16, use `Meilisearch\Bundle\SearchManagerInterface` instead. + */ interface SearchService { public const RESULT_KEY_HITS = 'hits'; diff --git a/src/Searchable.php b/src/Searchable.php index b4e3da0a..539339d3 100644 --- a/src/Searchable.php +++ b/src/Searchable.php @@ -4,8 +4,18 @@ namespace Meilisearch\Bundle; +/** + * @deprecated Since 0.16, use `Meilisearch\Bundle\SearchableObject` instead. + */ final class Searchable { + /** + * @deprecated use `Meilisearch\Bundle\SearchableObject::NORMALIZATION_FORMAT` instead + */ public const NORMALIZATION_FORMAT = 'searchableArray'; + + /** + * @deprecated use `Meilisearch\Bundle\SearchableObject::NORMALIZATION_GROUP` instead + */ public const NORMALIZATION_GROUP = 'searchable'; } diff --git a/src/SearchableEntity.php b/src/SearchableEntity.php index 705be5c8..76d2ef4f 100644 --- a/src/SearchableEntity.php +++ b/src/SearchableEntity.php @@ -12,6 +12,9 @@ use Symfony\Component\Serializer\Normalizer\NormalizableInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +/** + * @deprecated Since 0.16, use `Meilisearch\Bundle\SearchableObject` instead. + */ final class SearchableEntity { private string $indexUid; @@ -77,11 +80,11 @@ public function getSearchableArray(): array } if ($this->entity instanceof NormalizableInterface && null !== $this->normalizer) { - return $this->entity->normalize($this->normalizer, Searchable::NORMALIZATION_FORMAT, $context); + return $this->entity->normalize($this->normalizer, SearchableObject::NORMALIZATION_FORMAT, $context); } if (null !== $this->normalizer) { - return $this->normalizer->normalize($this->entity, Searchable::NORMALIZATION_FORMAT, $context); + return $this->normalizer->normalize($this->entity, SearchableObject::NORMALIZATION_FORMAT, $context); } return []; diff --git a/src/SearchableObject.php b/src/SearchableObject.php new file mode 100644 index 00000000..10036e00 --- /dev/null +++ b/src/SearchableObject.php @@ -0,0 +1,80 @@ + + */ + private array $normalizationContext; + + /** + * @param non-empty-string $indexUid + * @param non-empty-string $primaryKey + * @param array $normalizationContext + */ + public function __construct( + private readonly string $indexUid, + private readonly string $primaryKey, + private readonly object $object, + private readonly \Stringable|string|int $identifier, + private readonly NormalizerInterface $normalizer, + array $normalizationContext = [] + ) { + $this->normalizationContext = array_merge($normalizationContext, ['meilisearch' => true]); + } + + /** + * @return non-empty-string + */ + public function getIndexUid(): string + { + return $this->indexUid; + } + + /** + * @return non-empty-string + */ + public function getPrimaryKey(): string + { + return $this->primaryKey; + } + + public function getIdentifier(): \Stringable|string|int + { + return $this->identifier; + } + + /** + * @return array + * + * @throws ExceptionInterface + */ + public function getSearchableArray(): array + { + $context = $this->normalizationContext; + + if (Kernel::VERSION_ID >= 70100) { + $context[DateTimeNormalizer::FORMAT_KEY] = 'U'; + $context[DateTimeNormalizer::CAST_KEY] = 'int'; + } + + if ($this->object instanceof NormalizableInterface) { + return $this->object->normalize($this->normalizer, self::NORMALIZATION_FORMAT, $context); + } + + return $this->normalizer->normalize($this->object, self::NORMALIZATION_FORMAT, $context); + } +} diff --git a/src/Services/MeilisearchManager.php b/src/Services/MeilisearchManager.php new file mode 100644 index 00000000..9f6211da --- /dev/null +++ b/src/Services/MeilisearchManager.php @@ -0,0 +1,444 @@ + + */ + private array $searchables; + + /** + * @var array> + */ + private array $entitiesAggregators; + + /** + * @var list> + */ + private array $aggregators; + + /** + * @todo: config shape + * + * @param array $configuration + */ + public function __construct( + private readonly NormalizerInterface $normalizer, + private readonly Engine $engine, + private readonly PropertyAccessorInterface $propertyAccessor, + private readonly DataProviderRegistryInterface $dataProviderRegistry, + array $configuration, + ) { + $this->configuration = new Collection($configuration); + + $this->setSearchables(); + $this->setAggregatorsAndEntitiesAggregators(); + } + + public function isSearchable(object|string $className): bool + { + $className = $this->getBaseClassName($className); + + return \in_array($className, $this->searchables, true); + } + + public function searchableAs(string $className): string + { + $baseClassName = $this->getBaseClassName($className); + + $indexes = new Collection($this->getConfiguration()->get('indices')); + $index = $indexes->firstWhere('class', $baseClassName); + + if (null === $index) { + throw new NotSearchableException($baseClassName); + } + + return $this->getConfiguration()->get('prefix').$index['name']; + } + + public function getConfiguration(): Collection + { + return $this->configuration; + } + + public function index(object|array $searchable): array + { + $searchable = \is_array($searchable) ? $searchable : [$searchable]; + $batches = []; + + foreach ($searchable as $entity) { + $matchingIndices = $this->getIndicesForEntity($entity); + + foreach ($matchingIndices as $indexConfig) { + $indexName = $indexConfig['name']; + $configClass = $indexConfig['class']; + $baseClass = $this->getBaseClassName($entity); + + $provider = $this->dataProviderRegistry->getDataProvider($indexName, $baseClass); + + $objectToIndex = $entity; + if (!$entity instanceof $configClass && \in_array($configClass, $this->aggregators, true)) { + $objectToIndex = new $configClass( + $entity, + $provider->getIdentifierValues($entity), + $indexConfig['primary_key'], + ); + } + + if (!$this->shouldBeIndexed($objectToIndex, $indexConfig)) { + continue; + } + + if (!isset($batches[$indexName])) { + $batches[$indexName] = [ + 'config' => $indexConfig, + 'items' => [], + ]; + } + + $batches[$indexName]['items'][] = [ + 'object' => $objectToIndex, + 'provider' => $provider, + 'original_source' => $entity, + ]; + } + } + + $responses = []; + foreach ($batches as $batch) { + $responses[] = $this->batchProcess($batch['config'], $batch['items']); + } + + return array_merge(...$responses); + } + + public function remove(object|array $searchable): array + { + $searchable = \is_array($searchable) ? $searchable : [$searchable]; + $batches = []; + + foreach ($searchable as $entity) { + $matchingIndices = $this->getIndicesForEntity($entity); + + foreach ($matchingIndices as $indexConfig) { + $indexName = $indexConfig['name']; + $prefixedIndexName = $this->configuration->get('prefix').$indexName; + $baseClass = $this->getBaseClassName($entity); + $provider = $this->dataProviderRegistry->getDataProvider($indexName, $baseClass); + + $batches[$indexName][] = new SearchableObject( + $prefixedIndexName, + $indexConfig['primary_key'], + $entity, + $this->getSingleIdentifier($provider, $entity), // @todo: extension point? + $this->normalizer, + ['groups' => []] + ); + } + } + + $responses = []; + foreach ($batches as $objects) { + foreach (array_chunk($objects, $this->configuration->get('batchSize')) as $chunk) { + $responses[] = $this->engine->remove($chunk); + } + } + + return array_merge(...$responses); + } + + public function clear(string $className): array + { + $this->assertIsSearchable($className); + + return $this->engine->clear($this->searchableAs($className)); + } + + public function deleteByIndexName(string $indexName): array + { + return $this->engine->delete($indexName); + } + + public function delete(string $className): array + { + $this->assertIsSearchable($className); + + return $this->engine->delete($this->searchableAs($className)); + } + + public function search( + string $className, + string $query = '', + array $searchParams = [], + ): array { + $this->assertIsSearchable($className); + + $response = $this->engine->search($query, $this->searchableAs($className), $searchParams + ['limit' => $this->configuration->get('nbResults')]); + $hits = $response[self::RESULT_KEY_HITS]; + + if ([] === $hits) { + return []; + } + + $baseClassName = $this->getBaseClassName($className); + $indexes = new Collection($this->getConfiguration()->get('indices')); + $index = $indexes->firstWhere('class', $baseClassName); + + $identifiers = array_column($hits, $index['primary_key']); + + $dataProvider = $this->dataProviderRegistry->getDataProvider($index['name'], $baseClassName); + $loadedObjects = $dataProvider->loadByIdentifiers($identifiers); + $objectsById = []; + foreach ($loadedObjects as $object) { + $identifiers = $dataProvider->getIdentifierValues($object); + $key = 1 === \count($identifiers) ? (string) reset($identifiers) : implode('-', $identifiers); + $objectsById[$key] = $object; + } + $results = []; + + foreach ($hits as $hit) { + $documentId = $hit[$index['primary_key']]; + + if (isset($objectsById[$documentId])) { + $results[] = $objectsById[$documentId]; + } + } + + return $results; + } + + public function rawSearch( + string $className, + string $query = '', + array $searchParams = [] + ): array { + $this->assertIsSearchable($className); + + return $this->engine->search($query, $this->searchableAs($className), $searchParams); + } + + public function count(string $className, string $query = '', array $searchParams = []): int + { + $this->assertIsSearchable($className); + + return $this->engine->count($query, $this->searchableAs($className), $searchParams); + } + + /** + * @param array{index_if: string|null} $indexConfig + */ + private function shouldBeIndexed(object $entity, array $indexConfig): bool + { + if (null === $indexConfig['index_if']) { + return true; + } + + $propertyPath = $indexConfig['index_if']; + + if ($this->propertyAccessor->isReadable($entity, $propertyPath)) { + return (bool) $this->propertyAccessor->getValue($entity, $propertyPath); + } + + return false; + } + + /** + * @param object|class-string $objectOrClass + * + * @return class-string + */ + private function getBaseClassName(object|string $objectOrClass): string + { + if (\is_object($objectOrClass)) { + return self::resolveClass($objectOrClass); + } + + return $objectOrClass; + } + + private function setSearchables(): void + { + $this->searchables = array_unique(array_column($this->configuration->get('indices'), 'class')); + } + + private function setAggregatorsAndEntitiesAggregators(): void + { + $this->entitiesAggregators = []; + $this->aggregators = []; + + foreach ($this->configuration->get('indices') as $index) { + $className = $index['class']; + + if (is_subclass_of($className, Aggregator::class)) { + foreach ($className::getEntities() as $entityClass) { + $this->entitiesAggregators[$entityClass] ??= []; + $this->entitiesAggregators[$entityClass][] = [ + 'class' => $className, + 'index' => $index['name'], + ]; + $this->aggregators[] = (string) $className; + } + } + } + + $this->aggregators = array_unique($this->aggregators); + } + + /** + * @param array{ + * name: non-empty-string, + * enable_serializer_groups: bool, + * serializer_groups: array, + * primary_key: string, + * } $indexConfig + * @param array $items + */ + private function batchProcess(array $indexConfig, array $items): array + { + $batch = []; + $indexName = $indexConfig['name']; + $prefixedIndexName = $this->configuration->get('prefix').$indexName; + + $normalizationContext = []; + if (true === $indexConfig['enable_serializer_groups']) { + $normalizationContext['groups'] = $indexConfig['serializer_groups']; + } + + foreach (array_chunk($items, $this->configuration->get('batchSize')) as $chunk) { + $searchableChunk = []; + + foreach ($chunk as $item) { + $object = $item['object']; + $provider = $item['provider']; + $originalSource = $item['original_source']; + + $searchableChunk[] = new SearchableObject( + $prefixedIndexName, + $indexConfig['primary_key'], + $object, + $this->getSingleIdentifier($provider, $originalSource), // @todo: extension point? + $this->normalizer, + $normalizationContext, + ); + } + + $response = $this->engine->index($searchableChunk); + + if ([] !== $response) { + $batch[] = $response; + } + } + + return $batch; + } + + private function assertIsSearchable(string $className): void + { + if (!$this->isSearchable($className)) { + throw new NotSearchableException($className); + } + } + + private function getSingleIdentifier(DataProviderInterface $provider, object $object): string|int + { + $ids = $provider->getIdentifierValues($object); + + if (1 === \count($ids)) { + $identifier = reset($ids); + + if (\is_object($identifier) && method_exists($identifier, '__toString')) { + return (string) $identifier; + } + + return $identifier; + } + + $objectID = ''; + foreach ($ids as $key => $value) { + $objectID .= $key.'-'.$value.'__'; + } + + return $objectID; + } + + /** + * Returns ALL index configurations that apply to this entity. + */ + private function getIndicesForEntity(object $entity): array + { + $className = $this->getBaseClassName($entity); + $matchingConfigs = []; + + foreach ($this->configuration->get('indices') as $config) { + $configClass = $config['class']; + + // 1. Direct Match or Subclass + if ($className === $configClass || is_subclass_of($className, $configClass)) { + $matchingConfigs[] = $config; + } + + // 2. Aggregator Support + if (isset($this->entitiesAggregators[$className])) { + foreach ($this->entitiesAggregators[$className] as $aggInfo) { + if ($aggInfo['class'] === $configClass && $aggInfo['index'] === $config['name']) { + $matchingConfigs[] = $config; + } + } + } + } + + return $matchingConfigs; + } + + private static function resolveClass(object $object): string + { + static $resolver; + + $resolver ??= (function () use ($object) { + // Native lazy objects compatibility + if (PHP_VERSION_ID >= 80400 && class_exists(LegacyReflectionFields::class)) { + return fn (object $object) => \get_class($object); + } + + // Doctrine ORM v3+ compatibility + if (class_exists(DefaultProxyClassNameResolver::class)) { + return fn (object $object) => DefaultProxyClassNameResolver::getClass($object); + } + + // Legacy Doctrine ORM compatibility + return fn (object $object) => ClassUtils::getClass($object); // @codeCoverageIgnore + })(); + + return $resolver($object); + } +} diff --git a/src/Services/MeilisearchService.php b/src/Services/MeilisearchService.php index ad85706c..fffa4b5f 100644 --- a/src/Services/MeilisearchService.php +++ b/src/Services/MeilisearchService.php @@ -14,12 +14,16 @@ use Meilisearch\Bundle\Exception\ObjectIdNotFoundException; use Meilisearch\Bundle\Exception\SearchHitsNotFoundException; use Meilisearch\Bundle\SearchableEntity; +use Meilisearch\Bundle\SearchManagerInterface; use Meilisearch\Bundle\SearchService; use Symfony\Component\Config\Definition\Exception\Exception; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +/** + * @deprecated Since 0.16, use `Meilisearch\Bundle\Services\MeilisearchManager` instead. + */ final class MeilisearchService implements SearchService { private NormalizerInterface $normalizer; @@ -43,13 +47,15 @@ final class MeilisearchService implements SearchService */ private array $classToSerializerGroup; private array $indexIfMapping; + private ?SearchManagerInterface $manager; - public function __construct(NormalizerInterface $normalizer, Engine $engine, array $configuration, ?PropertyAccessorInterface $propertyAccessor = null) + public function __construct(NormalizerInterface $normalizer, Engine $engine, array $configuration, ?PropertyAccessorInterface $propertyAccessor = null, ?SearchManagerInterface $manager = null) { $this->normalizer = $normalizer; $this->engine = $engine; $this->configuration = new Collection($configuration); $this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor(); + $this->manager = $manager; $this->setSearchableEntities(); $this->setAggregatorsAndEntitiesAggregators(); @@ -59,11 +65,20 @@ public function __construct(NormalizerInterface $normalizer, Engine $engine, arr public function isSearchable($className): bool { + trigger_deprecation('meilisearch/meilisearch-symfony', '0.16', 'Using `Meilisearch\Bundle\Services\MeilisearchService::isSearchable()` is deprecated. Use `Meilisearch\Bundle\Services\MeilisearchManager::isSearchable()` instead.'); + + if (null !== $this->manager) { + return $this->manager->isSearchable($className); + } + $className = $this->getBaseClassName($className); return \in_array($className, $this->searchableEntities, true); } + /** + * @deprecated without replacement + */ public function getSearchable(): array { return $this->searchableEntities; @@ -71,11 +86,23 @@ public function getSearchable(): array public function getConfiguration(): Collection { + trigger_deprecation('meilisearch/meilisearch-symfony', '0.16', 'Using `Meilisearch\Bundle\Services\MeilisearchService::getConfiguration()` is deprecated. Use `Meilisearch\Bundle\Services\MeilisearchManager::getConfiguration()` instead.'); + + if (null !== $this->manager) { + return $this->manager->getConfiguration(); + } + return $this->configuration; } public function searchableAs(string $className): string { + trigger_deprecation('meilisearch/meilisearch-symfony', '0.16', 'Using `Meilisearch\Bundle\Services\MeilisearchService::searchableAs()` is deprecated. Use `Meilisearch\Bundle\Services\MeilisearchManager::searchableAs()` instead.'); + + if (null !== $this->manager) { + return $this->manager->searchableAs($className); + } + $className = $this->getBaseClassName($className); $indexes = new Collection($this->getConfiguration()->get('indices')); @@ -86,6 +113,12 @@ public function searchableAs(string $className): string public function index(ObjectManager $objectManager, $searchable): array { + trigger_deprecation('meilisearch/meilisearch-symfony', '0.16', 'Passing `Doctrine\Persistence\ObjectManager` to index() is deprecated. Use `Meilisearch\Bundle\Services\MeilisearchManager::index()` instead.'); + + if (null !== $this->manager) { + return $this->manager->index($searchable); + } + $searchable = \is_array($searchable) ? $searchable : [$searchable]; $searchable = array_merge($searchable, $this->getAggregatorsFromEntities($objectManager, $searchable)); @@ -115,6 +148,12 @@ public function index(ObjectManager $objectManager, $searchable): array public function remove(ObjectManager $objectManager, $searchable): array { + trigger_deprecation('meilisearch/meilisearch-symfony', '0.16', 'Passing `Doctrine\Persistence\ObjectManager` to remove() is deprecated. Use `Meilisearch\Bundle\Services\MeilisearchManager::remove()` instead.'); + + if (null !== $this->manager) { + return $this->manager->remove($searchable); + } + $searchable = \is_array($searchable) ? $searchable : [$searchable]; $searchable = array_merge($searchable, $this->getAggregatorsFromEntities($objectManager, $searchable)); @@ -132,6 +171,12 @@ public function remove(ObjectManager $objectManager, $searchable): array public function clear(string $className): array { + trigger_deprecation('meilisearch/meilisearch-symfony', '0.16', 'Using `Meilisearch\Bundle\Services\MeilisearchService::clear()` is deprecated. Use `Meilisearch\Bundle\Services\MeilisearchManager::clear()` instead.'); + + if (null !== $this->manager) { + return $this->manager->clear($className); + } + $this->assertIsSearchable($className); return $this->engine->clear($this->searchableAs($className)); @@ -139,11 +184,23 @@ public function clear(string $className): array public function deleteByIndexName(string $indexName): array { + trigger_deprecation('meilisearch/meilisearch-symfony', '0.16', 'Using `Meilisearch\Bundle\Services\MeilisearchService::deleteByIndexName()` is deprecated. Use `Meilisearch\Bundle\Services\MeilisearchManager::deleteByIndexName()` instead.'); + + if (null !== $this->manager) { + return $this->manager->deleteByIndexName($indexName); + } + return $this->engine->delete($indexName); } public function delete(string $className): array { + trigger_deprecation('meilisearch/meilisearch-symfony', '0.16', 'Using `Meilisearch\Bundle\Services\MeilisearchService::delete()` is deprecated. Use `Meilisearch\Bundle\Services\MeilisearchManager::delete()` instead.'); + + if (null !== $this->manager) { + return $this->manager->delete($className); + } + $this->assertIsSearchable($className); return $this->engine->delete($this->searchableAs($className)); @@ -155,9 +212,15 @@ public function search( string $query = '', array $searchParams = [] ): array { + trigger_deprecation('meilisearch/meilisearch-symfony', '0.16', 'Passing `Doctrine\Persistence\ObjectManager` to search() is deprecated. Use `Meilisearch\Bundle\Services\MeilisearchManager::search()` instead.'); + + if (null !== $this->manager) { + return $this->manager->search($className, $query, $searchParams); + } + $this->assertIsSearchable($className); - $ids = $this->engine->search($query, $this->searchableAs($className), $searchParams + ['limit' => $this->configuration['nbResults']]); + $ids = $this->engine->search($query, $this->searchableAs($className), $searchParams + ['limit' => $this->configuration->get('nbResults')]); $results = []; // Check if the engine returns results in "hits" key @@ -195,6 +258,12 @@ public function rawSearch( string $query = '', array $searchParams = [] ): array { + trigger_deprecation('meilisearch/meilisearch-symfony', '0.16', 'Using `Meilisearch\Bundle\Services\MeilisearchService::rawSearch()` is deprecated. Use `Meilisearch\Bundle\Services\MeilisearchManager::rawSearch()` instead.'); + + if (null !== $this->manager) { + return $this->manager->rawSearch($className, $query, $searchParams); + } + $this->assertIsSearchable($className); return $this->engine->search($query, $this->searchableAs($className), $searchParams); @@ -202,11 +271,20 @@ public function rawSearch( public function count(string $className, string $query = '', array $searchParams = []): int { + trigger_deprecation('meilisearch/meilisearch-symfony', '0.16', 'Using `Meilisearch\Bundle\Services\MeilisearchService::count()` is deprecated. Use `Meilisearch\Bundle\Services\MeilisearchManager::count()` instead.'); + + if (null !== $this->manager) { + return $this->manager->count($className, $query, $searchParams); + } + $this->assertIsSearchable($className); return $this->engine->count($query, $this->searchableAs($className), $searchParams); } + /** + * @deprecated without replacement + */ public function shouldBeIndexed(object $entity): bool { $className = $this->getBaseClassName($entity); @@ -265,8 +343,8 @@ private function setAggregatorsAndEntitiesAggregators(): void $this->entitiesAggregators[$entityClass] = []; } - $this->entitiesAggregators[$entityClass][] = $index['class']; - $this->aggregators[] = $index['class']; + $this->entitiesAggregators[$entityClass][] = (string) $index['class']; + $this->aggregators[] = (string) $index['class']; } } } diff --git a/src/Services/SettingsUpdater.php b/src/Services/SettingsUpdater.php index 3ef47aa9..51bcb805 100644 --- a/src/Services/SettingsUpdater.php +++ b/src/Services/SettingsUpdater.php @@ -9,7 +9,7 @@ use Meilisearch\Bundle\Exception\InvalidIndiceException; use Meilisearch\Bundle\Exception\InvalidSettingName; use Meilisearch\Bundle\Exception\TaskException; -use Meilisearch\Bundle\SearchService; +use Meilisearch\Bundle\SearchManagerInterface; use Meilisearch\Bundle\SettingsProvider; use Meilisearch\Client; use Symfony\Component\EventDispatcher\EventDispatcherInterface; @@ -18,15 +18,14 @@ final class SettingsUpdater { private const DEFAULT_RESPONSE_TIMEOUT = 5000; - private Client $searchClient; - private EventDispatcherInterface $eventDispatcher; private Collection $configuration; - public function __construct(SearchService $searchService, Client $searchClient, EventDispatcherInterface $eventDispatcher) - { - $this->searchClient = $searchClient; - $this->eventDispatcher = $eventDispatcher; - $this->configuration = $searchService->getConfiguration(); + public function __construct( + SearchManagerInterface $searchManager, + private readonly Client $searchClient, + private readonly EventDispatcherInterface $eventDispatcher, + ) { + $this->configuration = $searchManager->getConfiguration(); } /** diff --git a/src/Services/UnixTimestampNormalizer.php b/src/Services/UnixTimestampNormalizer.php index 93b527ae..ffc7be35 100644 --- a/src/Services/UnixTimestampNormalizer.php +++ b/src/Services/UnixTimestampNormalizer.php @@ -9,17 +9,14 @@ final class UnixTimestampNormalizer implements NormalizerInterface { /** - * @param \DateTimeInterface $object + * @param \DateTimeInterface $data */ - public function normalize($object, ?string $format = null, array $context = []): int + public function normalize(mixed $data, ?string $format = null, array $context = []): int { - return $object->getTimestamp(); + return $data->getTimestamp(); } - /** - * @param mixed $data - */ - public function supportsNormalization($data, ?string $format = null, array $context = []): bool + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $data instanceof \DateTimeInterface && true === ($context['meilisearch'] ?? null); } diff --git a/tests/BaseKernelTestCase.php b/tests/BaseKernelTestCase.php index 76f07f57..6dba60b1 100644 --- a/tests/BaseKernelTestCase.php +++ b/tests/BaseKernelTestCase.php @@ -7,7 +7,7 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Tools\SchemaTool; use Meilisearch\Bundle\Collection; -use Meilisearch\Bundle\SearchService; +use Meilisearch\Bundle\SearchManagerInterface; use Meilisearch\Client; use Meilisearch\Exceptions\ApiException; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -16,7 +16,7 @@ abstract class BaseKernelTestCase extends KernelTestCase { protected EntityManagerInterface $entityManager; protected Client $client; - protected SearchService $searchService; + protected SearchManagerInterface $searchManager; protected function setUp(): void { @@ -24,7 +24,7 @@ protected function setUp(): void $this->entityManager = $this->get('doctrine.orm.entity_manager'); $this->client = $this->get('meilisearch.client'); - $this->searchService = $this->get('meilisearch.service'); + $this->searchManager = $this->get('meilisearch.manager'); $metaData = $this->entityManager->getMetadataFactory()->getAllMetadata(); $tool = new SchemaTool($this->entityManager); @@ -36,7 +36,7 @@ protected function setUp(): void protected function getPrefix(): string { - return $this->searchService->getConfiguration()->get('prefix'); + return $this->searchManager->getConfiguration()->get('prefix'); } protected function get(string $id): ?object @@ -46,13 +46,14 @@ protected function get(string $id): ?object protected function waitForAllTasks(): void { - $firstTask = $this->client->getTasks()->getResults()[0]; - $this->client->waitForTask($firstTask['uid']); + foreach ($this->client->getTasks() as $task) { + $this->client->waitForTask($task['uid']); + } } private function cleanUp(): void { - (new Collection($this->searchService->getConfiguration()->get('indices'))) + (new Collection($this->searchManager->getConfiguration()->get('indices'))) ->each(function ($item): bool { $this->cleanupIndex($item['prefixed_name']); @@ -63,7 +64,7 @@ private function cleanUp(): void private function cleanupIndex(string $indexName): void { try { - $this->searchService->deleteByIndexName($indexName); + $this->searchManager->deleteByIndexName($indexName); } catch (ApiException $e) { // Don't assert undefined indexes. // Just plainly delete all existing indexes to get a clean state. diff --git a/tests/Entity/Actor.php b/tests/Entity/Actor.php new file mode 100644 index 00000000..2b6e6395 --- /dev/null +++ b/tests/Entity/Actor.php @@ -0,0 +1,14 @@ + $this->id, 'name' => $this->name, diff --git a/tests/Entity/SelfNormalizable.php b/tests/Entity/SelfNormalizable.php index 3895424e..95bfca54 100644 --- a/tests/Entity/SelfNormalizable.php +++ b/tests/Entity/SelfNormalizable.php @@ -6,7 +6,7 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; -use Meilisearch\Bundle\Searchable; +use Meilisearch\Bundle\SearchableObject; use Symfony\Component\Serializer\Normalizer\NormalizableInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -63,7 +63,7 @@ public function getCreatedAt(): \DateTimeImmutable public function normalize(NormalizerInterface $normalizer, $format = null, array $context = []): array { - if (Searchable::NORMALIZATION_FORMAT === $format) { + if (SearchableObject::NORMALIZATION_FORMAT === $format) { return [ 'id' => $this->id, 'name' => 'this test is correct', diff --git a/tests/Entity/Tag.php b/tests/Entity/Tag.php index 63f11fbd..1bf5a3ab 100644 --- a/tests/Entity/Tag.php +++ b/tests/Entity/Tag.php @@ -6,7 +6,7 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; -use Meilisearch\Bundle\Searchable; +use Meilisearch\Bundle\SearchableObject; use Symfony\Component\Serializer\Exception\ExceptionInterface; use Symfony\Component\Serializer\Normalizer\NormalizableInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -87,7 +87,7 @@ public function setPublic(bool $public): Tag */ public function normalize(NormalizerInterface $normalizer, $format = null, array $context = []): array { - if (Searchable::NORMALIZATION_FORMAT === $format) { + if (SearchableObject::NORMALIZATION_FORMAT === $format) { return [ 'id' => $this->id, 'name' => 'this test is correct', diff --git a/tests/Integration/AggregatorTest.php b/tests/Integration/AggregatorTest.php index 762d199c..b4626f09 100644 --- a/tests/Integration/AggregatorTest.php +++ b/tests/Integration/AggregatorTest.php @@ -22,16 +22,24 @@ public function testGetEntities(): void } public function testGetEntityClassFromObjectID(): void + { + self::assertSame(Post::class, ContentAggregator::getEntityClassFromObjectID(Post::class.'::123')); + } + + public function testGetEntityClassFromObjectIDWithUnknownEntityThrows(): void { $this->expectException(EntityNotFoundInObjectID::class); + EmptyAggregator::getEntityClassFromObjectID('test'); } - public function testConstructor(): void + public function testConstructorThrowsWithMoreThanOnePrimaryKey(): void { - $this->expectException(InvalidEntityForAggregator::class); $post = new Post(); - new ContentAggregator($post, ['objectId', 'url']); + + $this->expectException(InvalidEntityForAggregator::class); + + new ContentAggregator($post, ['objectId', 'url'], 'objectId'); } public function testAggregatorProxyClass(): void @@ -47,7 +55,40 @@ public function testAggregatorProxyClass(): void $proxy = $this->entityManager->getReference(Post::class, $postId); $this->assertInstanceOf(Proxy::class, $proxy); - $contentAggregator = new ContentAggregator($proxy, ['objectId']); + $contentAggregator = new ContentAggregator($proxy, ['objectId'], 'objectId'); + + /** @var Serializer $serializer */ + $serializer = $this->get('serializer'); + + $serializedData = $contentAggregator->normalize($serializer); + + $this->assertNotEmpty($serializedData); + $this->assertEquals('objectId', $serializedData['objectId']); + } + + public function testAggregatorNormalization(): void + { + $this->entityManager->persist($post = new Post()); + $this->entityManager->flush(); + + $contentAggregator = new ContentAggregator($post, [$post->getId()]); + + /** @var Serializer $serializer */ + $serializer = $this->get('serializer'); + + $serializedData = $contentAggregator->normalize($serializer); + + $this->assertNotEmpty($serializedData); + $this->assertSame((string) $post->getId(), $serializedData['objectID']); + $this->assertSame($post->getId(), $serializedData['id']); + } + + public function testAggregatorCustomPrimaryKey(): void + { + $this->entityManager->persist($post = new Post()); + $this->entityManager->flush(); + + $contentAggregator = new ContentAggregator($post, [$post->getId()], 'id'); /** @var Serializer $serializer */ $serializer = $this->get('serializer'); @@ -55,6 +96,6 @@ public function testAggregatorProxyClass(): void $serializedData = $contentAggregator->normalize($serializer); $this->assertNotEmpty($serializedData); - $this->assertEquals('objectId', $serializedData['objectID']); + $this->assertSame($post->getId(), $serializedData['id']); } } diff --git a/tests/Integration/Command/MeilisearchClearCommandTest.php b/tests/Integration/Command/MeilisearchClearCommandTest.php index 19c4b64c..3edb81f8 100644 --- a/tests/Integration/Command/MeilisearchClearCommandTest.php +++ b/tests/Integration/Command/MeilisearchClearCommandTest.php @@ -36,6 +36,7 @@ public function testClear(): void Cleared sf_phpunit__self_normalizable index of Meilisearch\Bundle\Tests\Entity\SelfNormalizable Cleared sf_phpunit__dummy_custom_groups index of Meilisearch\Bundle\Tests\Entity\DummyCustomGroups Cleared sf_phpunit__dynamic_settings index of Meilisearch\Bundle\Tests\Entity\DynamicSettings +Cleared sf_phpunit__actor index of Meilisearch\Bundle\Tests\Entity\Actor Done! EOD, $commandTester->getDisplay()); diff --git a/tests/Integration/Command/MeilisearchCreateCommandTest.php b/tests/Integration/Command/MeilisearchCreateCommandTest.php index 6a658f45..69f049e5 100644 --- a/tests/Integration/Command/MeilisearchCreateCommandTest.php +++ b/tests/Integration/Command/MeilisearchCreateCommandTest.php @@ -60,6 +60,7 @@ public function testWithoutIndices(bool $updateSettings): void Setting "searchableAttributes" updated of "sf_phpunit__dynamic_settings". Setting "stopWords" updated of "sf_phpunit__dynamic_settings". Setting "synonyms" updated of "sf_phpunit__dynamic_settings". +Creating index sf_phpunit__actor for Meilisearch\Bundle\Tests\Entity\Actor Creating index sf_phpunit__aggregated for Meilisearch\Bundle\Tests\Entity\Post Creating index sf_phpunit__aggregated for Meilisearch\Bundle\Tests\Entity\Tag Done! @@ -76,6 +77,7 @@ public function testWithoutIndices(bool $updateSettings): void Creating index sf_phpunit__self_normalizable for Meilisearch\Bundle\Tests\Entity\SelfNormalizable Creating index sf_phpunit__dummy_custom_groups for Meilisearch\Bundle\Tests\Entity\DummyCustomGroups Creating index sf_phpunit__dynamic_settings for Meilisearch\Bundle\Tests\Entity\DynamicSettings +Creating index sf_phpunit__actor for Meilisearch\Bundle\Tests\Entity\Actor Creating index sf_phpunit__aggregated for Meilisearch\Bundle\Tests\Entity\Post Creating index sf_phpunit__aggregated for Meilisearch\Bundle\Tests\Entity\Tag Done! diff --git a/tests/Integration/Command/MeilisearchDeleteCommandTest.php b/tests/Integration/Command/MeilisearchDeleteCommandTest.php index 94c00276..4d068540 100644 --- a/tests/Integration/Command/MeilisearchDeleteCommandTest.php +++ b/tests/Integration/Command/MeilisearchDeleteCommandTest.php @@ -37,6 +37,7 @@ public function testDeleteWithoutIndices(): void Deleted sf_phpunit__self_normalizable Deleted sf_phpunit__dummy_custom_groups Deleted sf_phpunit__dynamic_settings +Deleted sf_phpunit__actor Done! EOD, $clearOutput); diff --git a/tests/Integration/Command/MeilisearchImportCommandTest.php b/tests/Integration/Command/MeilisearchImportCommandTest.php index a9e09610..c76cb3f1 100644 --- a/tests/Integration/Command/MeilisearchImportCommandTest.php +++ b/tests/Integration/Command/MeilisearchImportCommandTest.php @@ -180,8 +180,8 @@ public function testImportDifferentEntitiesIntoSameIndex(): void $this->assertSame(<<<'EOD' Importing for index Meilisearch\Bundle\Tests\Entity\Tag -Indexed a batch of 6 / 6 Meilisearch\Bundle\Tests\Entity\Tag entities into sf_phpunit__tags index (6 indexed since start) Indexed a batch of 6 / 6 Meilisearch\Bundle\Tests\Entity\Tag entities into sf_phpunit__aggregated index (6 indexed since start) +Indexed a batch of 6 / 6 Meilisearch\Bundle\Tests\Entity\Tag entities into sf_phpunit__tags index (6 indexed since start) Importing for index Meilisearch\Bundle\Tests\Entity\Link Indexed a batch of 2 / 2 Meilisearch\Bundle\Tests\Entity\Link entities into sf_phpunit__tags index (2 indexed since start) Done! diff --git a/tests/Integration/EngineTest.php b/tests/Integration/EngineTest.php index 483f3bf2..e96b74d5 100644 --- a/tests/Integration/EngineTest.php +++ b/tests/Integration/EngineTest.php @@ -5,7 +5,7 @@ namespace Meilisearch\Bundle\Tests\Integration; use Meilisearch\Bundle\Engine; -use Meilisearch\Bundle\SearchableEntity; +use Meilisearch\Bundle\SearchableObject; use Meilisearch\Bundle\Tests\BaseKernelTestCase; use Meilisearch\Bundle\Tests\Entity\Image; use Meilisearch\Bundle\Tests\Entity\Post; @@ -32,11 +32,13 @@ public function testIndexingEmptyEntity(): void $this->entityManager->persist($image); $this->entityManager->flush(); - $searchableImage = new SearchableEntity( + $searchableImage = new SearchableObject( $this->getPrefix().'image', + 'objectID', $image, - $this->get('doctrine')->getManager()->getClassMetadata(Image::class), - null + $image->getId(), + $this->get('serializer'), + ['groups' => ['force_empty']], ); // Remove @@ -57,16 +59,14 @@ public function testIndexingEmptyEntity(): void public function testRemovingMultipleEntity(): void { - $metadata = $this->get('doctrine')->getManager()->getClassMetadata(Post::class); - $serializer = $this->get('serializer'); - $this->entityManager->persist($post1 = new Post()); $this->entityManager->persist($post2 = new Post()); $this->entityManager->flush(); - $postSearchable1 = new SearchableEntity($this->getPrefix().'posts', $post1, $metadata, $serializer); - $postSearchable2 = new SearchableEntity($this->getPrefix().'posts', $post2, $metadata, $serializer); + $serializer = $this->get('serializer'); + $postSearchable1 = new SearchableObject($this->getPrefix().'posts', 'objectID', $post1, $post1->getId(), $serializer); + $postSearchable2 = new SearchableObject($this->getPrefix().'posts', 'objectID', $post2, $post2->getId(), $serializer); $result = $this->engine->remove([$postSearchable1, $postSearchable2]); @@ -78,8 +78,6 @@ public function testRemovingMultipleEntity(): void foreach ([$postSearchable1, $postSearchable2] as $post) { $searchResult = $this->engine->search('', $post->getIndexUid(), []); - $this->assertArrayHasKey('hits', $searchResult); - $this->assertIsArray($searchResult['hits']); $this->assertEmpty($searchResult['hits']); } } diff --git a/tests/Integration/EventListener/DoctrineEventSubscriberTest.php b/tests/Integration/EventListener/DoctrineEventSubscriberTest.php index 8899cb49..9356c0d7 100644 --- a/tests/Integration/EventListener/DoctrineEventSubscriberTest.php +++ b/tests/Integration/EventListener/DoctrineEventSubscriberTest.php @@ -20,7 +20,7 @@ public function testPostPersist(): void $this->waitForAllTasks(); - $result = $this->searchService->search($this->entityManager, Post::class, $post->getTitle()); + $result = $this->searchManager->search(Post::class, $post->getTitle()); $this->assertCount(1, $result); $this->assertSame($post->getId(), $result[0]->getId()); @@ -35,7 +35,7 @@ public function testPostPersistWithObjectId(): void $this->waitForAllTasks(); - $result = $this->searchService->search($this->entityManager, Page::class, $page->getTitle()); + $result = $this->searchManager->search(Page::class, $page->getTitle()); $this->assertCount(1, $result); $this->assertEquals(new DummyObjectId(1), $result[0]->getId()); @@ -56,7 +56,7 @@ public function testPostUpdate(): void $this->waitForAllTasks(); - $result = $this->searchService->search($this->entityManager, Post::class, 'better'); + $result = $this->searchManager->search(Post::class, 'better'); $this->assertCount(1, $result); $this->assertSame($post->getId(), $result[0]->getId()); @@ -78,7 +78,7 @@ public function testPostUpdateWithObjectId(): void $this->waitForAllTasks(); - $result = $this->searchService->search($this->entityManager, Page::class, 'better'); + $result = $this->searchManager->search(Page::class, 'better'); $this->assertCount(1, $result); $this->assertEquals(new DummyObjectId(1), $result[0]->getId()); @@ -94,7 +94,7 @@ public function testPreRemove(): void $this->waitForAllTasks(); - $result = $this->searchService->search($this->entityManager, Post::class, $post->getTitle()); + $result = $this->searchManager->search(Post::class, $post->getTitle()); $this->assertCount(1, $result); $this->assertSame($post->getId(), $result[0]->getId()); @@ -104,7 +104,7 @@ public function testPreRemove(): void $this->waitForAllTasks(); - $result = $this->searchService->search($this->entityManager, Post::class, $post->getTitle()); + $result = $this->searchManager->search(Post::class, $post->getTitle()); $this->assertCount(0, $result); } @@ -118,7 +118,7 @@ public function testPreRemoveWithObjectId(): void $this->waitForAllTasks(); - $result = $this->searchService->search($this->entityManager, Page::class, $page->getTitle()); + $result = $this->searchManager->search(Page::class, $page->getTitle()); $this->assertCount(1, $result); $this->assertEquals($page->getId(), $result[0]->getId()); @@ -128,7 +128,7 @@ public function testPreRemoveWithObjectId(): void $this->waitForAllTasks(); - $result = $this->searchService->search($this->entityManager, Page::class, $page->getTitle()); + $result = $this->searchManager->search(Page::class, $page->getTitle()); $this->assertCount(0, $result); } diff --git a/tests/Integration/Fixtures/ActorDataProvider.php b/tests/Integration/Fixtures/ActorDataProvider.php new file mode 100644 index 00000000..c756f2d5 --- /dev/null +++ b/tests/Integration/Fixtures/ActorDataProvider.php @@ -0,0 +1,70 @@ + + */ +final class ActorDataProvider implements DataProviderInterface +{ + public function provide(int $limit, int $offset): array + { + return \array_slice([ + new Actor(1, 'Jack Nicholson'), + new Actor(2, 'Marlon Brando'), + new Actor(3, 'Robert De Niro'), + new Actor(4, 'Al Pacino'), + new Actor(5, 'Daniel Day-Lewis'), + new Actor(6, 'Dustin Hoffman'), + new Actor(7, 'Tom Hanks'), + new Actor(8, 'Anthony Hopkins'), + new Actor(9, 'Paul Newman'), + new Actor(10, 'Denzel Washington'), + new Actor(11, 'Spencer Tracy'), + new Actor(12, 'Laurence Olivier'), + new Actor(13, 'Jack Lemmon'), + new Actor(14, 'Michael Caine'), + new Actor(15, 'James Stewart'), + new Actor(16, 'Robin Williams'), + new Actor(17, 'Robert Duvall'), + new Actor(18, 'Sean Penn'), + new Actor(19, 'Morgan Freeman'), + new Actor(20, 'Jeff Bridges'), + new Actor(21, 'Sidney Poitier'), + new Actor(22, 'Peter O\'Toole'), + new Actor(23, 'Clint Eastwood'), + new Actor(24, 'Gene Hackman'), + new Actor(25, 'Charles Chaplin'), + ], $offset, $limit); + } + + public function loadByIdentifiers(array $identifiers): array + { + $actors = []; + + foreach ($this->provide(PHP_INT_MAX, 0) as $actor) { + if ($actor->id === $identifiers['id']) { + $actors[] = $actor; + } + } + + return $actors; + } + + public function getIdentifierValues(object $object): array + { + \assert($object instanceof Actor); + + return ['id' => $object->id]; + } + + public function cleanup(): void + { + // noop + } +} diff --git a/tests/Integration/SearchTest.php b/tests/Integration/SearchTest.php index db41ab77..7a9cf11a 100644 --- a/tests/Integration/SearchTest.php +++ b/tests/Integration/SearchTest.php @@ -68,15 +68,17 @@ public function testSearchImportAggregator(): void $this->assertStringContainsString('Indexed a batch of 1 / 1 Meilisearch\Bundle\Tests\Entity\Tag entities into sf_phpunit__'.self::$indexName.' index (1 indexed since start)', $output); $this->assertStringContainsString('Done!', $output); + $this->waitForAllTasks(); + $searchTerm = 'Test'; - $results = $this->searchService->search($this->objectManager, Post::class, $searchTerm); + $results = $this->searchManager->search(Post::class, $searchTerm); $this->assertCount(5, $results); $resultTitles = array_map(static fn (Post $post) => $post->getTitle(), $results); $this->assertEqualsCanonicalizing($testDataTitles, $resultTitles); - $results = $this->searchService->rawSearch(Post::class, $searchTerm); + $results = $this->searchManager->rawSearch(Post::class, $searchTerm); $this->assertCount(5, $results['hits']); $resultTitles = array_map(static fn (array $hit) => $hit['title'], $results['hits']); @@ -85,7 +87,7 @@ public function testSearchImportAggregator(): void $this->assertCount(5, $results['hits']); $this->assertSame(5, $results['nbHits']); - $results = $this->searchService->search($this->objectManager, Tag::class, $searchTerm); + $results = $this->searchManager->search(Tag::class, $searchTerm); $this->assertCount(1, $results); } @@ -109,9 +111,9 @@ public function testSearchPagination(): void '--indices' => $this->index->getUid(), ]); - $searchTerm = 'Test'; + $this->waitForAllTasks(); - $results = $this->searchService->search($this->objectManager, Post::class, $searchTerm, ['page' => 2, 'hitsPerPage' => 2]); + $results = $this->searchManager->search(Post::class, 'Test', ['page' => 2, 'hitsPerPage' => 2]); $this->assertCount(2, $results); $resultTitles = array_map(static fn (Post $post) => $post->getTitle(), $results); @@ -132,7 +134,9 @@ public function testSearchNbResults(): void '--indices' => $this->index->getUid(), ]); - $results = $this->searchService->search($this->objectManager, Post::class, 'test'); + $this->waitForAllTasks(); + + $results = $this->searchManager->search(Post::class, 'test'); $this->assertCount(12, $results); } diff --git a/tests/Kernel.php b/tests/Kernel.php index da870126..c4230071 100644 --- a/tests/Kernel.php +++ b/tests/Kernel.php @@ -33,22 +33,12 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa $doctrineBundleV3 = !class_exists(BlacklistSchemaAssetFilter::class); - if (PHP_VERSION_ID >= 80000) { - if ($doctrineBundleV3) { - $loader->load(__DIR__.'/config/doctrine.yaml'); - } elseif (class_exists(LegacyReflectionFields::class) && PHP_VERSION_ID >= 80400) { - $loader->load(__DIR__.'/config/doctrine_v2.yaml'); - } else { - $loader->load(__DIR__.'/config/doctrine_old_proxy.yaml'); - } + if ($doctrineBundleV3) { + $loader->load(__DIR__.'/config/doctrine.yaml'); + } elseif (class_exists(LegacyReflectionFields::class) && PHP_VERSION_ID >= 80400) { + $loader->load(__DIR__.'/config/doctrine_v2.yaml'); } else { - $container->prependExtensionConfig('framework', [ - 'annotations' => true, - 'serializer' => ['enable_annotations' => true], - 'router' => ['utf8' => true], - ]); - - $loader->load(__DIR__.'/config/doctrine_php7.yaml'); + $loader->load(__DIR__.'/config/doctrine_old_proxy.yaml'); } $loader->load(__DIR__.'/config/meilisearch.yaml'); @@ -71,13 +61,6 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa ]); } - // @phpstan-ignore-next-line - if (Kernel::VERSION_ID >= 60400) { - $container->prependExtensionConfig('framework', [ - 'handle_all_throwables' => true, - 'php_errors' => ['log' => true], - ]); - } // @phpstan-ignore-next-line if (Kernel::VERSION_ID >= 70300) { $container->prependExtensionConfig('framework', [ diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index a6789e07..f358d3e1 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -136,6 +136,9 @@ public static function dataTestConfigurationTree(): iterable 'serializer_groups' => ['searchable'], 'index_if' => null, 'settings' => [], + 'type' => 'orm', + 'data_provider' => null, + 'primary_key' => 'objectID', ], 1 => [ 'name' => 'tags', @@ -144,6 +147,9 @@ public static function dataTestConfigurationTree(): iterable 'serializer_groups' => ['searchable'], 'index_if' => null, 'settings' => [], + 'type' => 'orm', + 'data_provider' => null, + 'primary_key' => 'objectID', ], ], ], @@ -184,7 +190,11 @@ public static function dataTestConfigurationTree(): iterable 'class' => 'App\Entity\Post', 'enable_serializer_groups' => false, 'serializer_groups' => ['searchable'], - 'index_if' => null, 'settings' => [], + 'index_if' => null, + 'settings' => [], + 'type' => 'orm', + 'data_provider' => null, + 'primary_key' => 'objectID', ], [ 'name' => 'items', @@ -193,6 +203,9 @@ public static function dataTestConfigurationTree(): iterable 'serializer_groups' => ['searchable'], 'index_if' => null, 'settings' => [], + 'type' => 'orm', + 'data_provider' => null, + 'primary_key' => 'objectID', ], ], 'nbResults' => 20, @@ -228,7 +241,11 @@ public static function dataTestConfigurationTree(): iterable 'class' => 'App\Entity\Post', 'enable_serializer_groups' => true, 'serializer_groups' => ['post.public', 'post.private'], - 'index_if' => null, 'settings' => [], + 'index_if' => null, + 'settings' => [], + 'type' => 'orm', + 'data_provider' => null, + 'primary_key' => 'objectID', ], ], 'nbResults' => 20, @@ -267,6 +284,9 @@ public static function dataTestConfigurationTree(): iterable 'settings' => [ 'distinctAttribute' => ['product_id'], ], + 'type' => 'orm', + 'data_provider' => null, + 'primary_key' => 'objectID', ], ], 'nbResults' => 20, @@ -305,6 +325,9 @@ public static function dataTestConfigurationTree(): iterable 'settings' => [ 'proximityPrecision' => ['byWord'], ], + 'type' => 'orm', + 'data_provider' => null, + 'primary_key' => 'objectID', ], ], 'nbResults' => 20, @@ -332,6 +355,51 @@ public static function dataTestConfigurationTree(): iterable 'http_client' => 'acme.http_client', ], ]; + + yield 'custom primary key' => [ + 'inputConfig' => [ + 'meilisearch' => [ + 'prefix' => 'sf_', + 'indices' => [ + ['name' => 'posts', 'class' => 'App\Entity\Post', 'primary_key' => 'postId'], + ['name' => 'tags', 'class' => 'App\Entity\Tag', 'primary_key' => 'tagId'], + ], + ], + ], + 'expectedConfig' => [ + 'url' => 'http://localhost:7700', + 'prefix' => 'sf_', + 'nbResults' => 20, + 'batchSize' => 500, + 'serializer' => 'serializer', + 'doctrineSubscribedEvents' => ['postPersist', 'postUpdate', 'preRemove'], + 'http_client' => 'psr18.http_client', + 'indices' => [ + 0 => [ + 'name' => 'posts', + 'class' => 'App\Entity\Post', + 'enable_serializer_groups' => false, + 'serializer_groups' => ['searchable'], + 'index_if' => null, + 'settings' => [], + 'type' => 'orm', + 'data_provider' => null, + 'primary_key' => 'postId', + ], + 1 => [ + 'name' => 'tags', + 'class' => 'App\Entity\Tag', + 'enable_serializer_groups' => false, + 'serializer_groups' => ['searchable'], + 'index_if' => null, + 'settings' => [], + 'type' => 'orm', + 'data_provider' => null, + 'primary_key' => 'tagId', + ], + ], + ], + ]; } /** diff --git a/tests/Unit/SerializationTest.php b/tests/Unit/SerializationTest.php index 631542b7..77fad973 100644 --- a/tests/Unit/SerializationTest.php +++ b/tests/Unit/SerializationTest.php @@ -4,8 +4,7 @@ namespace Meilisearch\Bundle\Tests\Unit; -use Meilisearch\Bundle\Searchable; -use Meilisearch\Bundle\SearchableEntity; +use Meilisearch\Bundle\SearchableObject; use Meilisearch\Bundle\Tests\BaseKernelTestCase; use Meilisearch\Bundle\Tests\Entity\Comment; use Meilisearch\Bundle\Tests\Entity\Post; @@ -16,20 +15,18 @@ public function testSimpleEntityToSearchableArray(): void { $post = new Post('a simple post', 'some text', $datetime = new \DateTimeImmutable('@1728994403')); $idReflection = (new \ReflectionObject($post))->getProperty('id'); - if (PHP_VERSION_ID < 80000) { - $idReflection->setAccessible(true); - } $idReflection->setValue($post, 12); $comment = new Comment($post, 'a great comment', $datetime); $post->addComment($comment); - $searchablePost = new SearchableEntity( + $searchablePost = new SearchableObject( 'posts', + 'id', $post, - self::getContainer()->get('doctrine')->getManager()->getClassMetadata(Post::class), + $post->getId(), self::getContainer()->get('serializer'), - ['normalizationGroups' => [Searchable::NORMALIZATION_GROUP]] + ['groups' => [SearchableObject::NORMALIZATION_GROUP]] ); $this->assertSame([ diff --git a/tests/baseline-ignore b/tests/baseline-ignore index 2a7dfb0d..59ca12be 100644 --- a/tests/baseline-ignore +++ b/tests/baseline-ignore @@ -2,6 +2,7 @@ %Since symfony/var-exporter 7.3: The "Symfony\\Component\\VarExporter\\LazyGhostTrait" trait is deprecated, use native lazy objects instead.% %Since symfony/var-exporter 7.3: Using ProxyHelper::generateLazyGhost\(\) is deprecated, use native lazy objects instead.% %Class "Doctrine\\ORM\\Proxy\\Autoloader" is deprecated. Use native lazy objects instead.% +%Class "Doctrine\\ORM\\Proxy\\DefaultProxyClassNameResolver" is deprecated. Use native lazy objects instead.% %Calling Doctrine\\ORM\\Configuration::(setProxyDir|getProxyDir) is deprecated and will not be possible in Doctrine ORM 4.0% %Calling Doctrine\\ORM\\Configuration::(setAutoGenerateProxyClasses|getAutoGenerateProxyClasses) is deprecated and will not be possible in Doctrine ORM 4.0% %Calling Doctrine\\ORM\\Configuration::setProxyNamespace is deprecated and will not be possible in Doctrine ORM 4.0% diff --git a/tests/config/doctrine_php7.yaml b/tests/config/doctrine_php7.yaml deleted file mode 100644 index ee4a73a5..00000000 --- a/tests/config/doctrine_php7.yaml +++ /dev/null @@ -1,20 +0,0 @@ -doctrine: - dbal: - default_connection: default - connections: - default: - driver: pdo_sqlite - path: '%kernel.cache_dir%/test.sqlite' - types: - dummy_object_id: Meilisearch\Bundle\Tests\Dbal\Type\DummyObjectIdType - orm: - auto_generate_proxy_classes: true - naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware - auto_mapping: true - mappings: - App: - is_bundle: false - type: annotation - dir: '%kernel.project_dir%/tests/Entity' - prefix: 'Meilisearch\Bundle\Tests\Entity' - alias: App diff --git a/tests/config/framework.yaml b/tests/config/framework.yaml index f94709a2..977e2d08 100644 --- a/tests/config/framework.yaml +++ b/tests/config/framework.yaml @@ -2,3 +2,6 @@ framework: test: true secret: 67d829bf61dc5f87a73fd814e2c9f629 http_method_override: false + handle_all_throwables: true + php_errors: + log: true diff --git a/tests/config/meilisearch.yaml b/tests/config/meilisearch.yaml index 50a06b90..98d8e72d 100644 --- a/tests/config/meilisearch.yaml +++ b/tests/config/meilisearch.yaml @@ -54,6 +54,10 @@ meilisearch: _service: '@Meilisearch\Bundle\Tests\Integration\Fixtures\StopWords' synonyms: _service: '@Meilisearch\Bundle\Tests\Integration\Fixtures\Synonyms' + - name: actor + class: 'Meilisearch\Bundle\Tests\Entity\Actor' + type: 'custom' + data_provider: Meilisearch\Bundle\Tests\Integration\Fixtures\ActorDataProvider services: Meilisearch\Bundle\Tests\Integration\Fixtures\: