diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index 10b413a0..f1811b7c 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -1,15 +1,12 @@ landing_getting_started_1: |- - ```yml - # Configure your entities by adding them in config/packages/meilisearch.yaml - meilisearch: - url: 'http://127.0.0.1:7700' - api_key: 'masterKey' - indices: - - name: movies - class: App\Entity\Movie - ``` - - ```php + // # Configure your entities by adding them in config/packages/meilisearch.yaml + // meilisearch: + // url: 'http://127.0.0.1:7700' + // api_key: 'masterKey' + // indices: + // - name: movies + // class: App\Entity\Movie + "$temp_file" +latest_rc_release=$(cat "$temp_file" \ + | grep -E 'tag_name' | grep 'rc' | head -1 \ + | tr -d ',"' | cut -d ':' -f2 | tr -d ' ') +rm -rf "$temp_file" +echo "$latest_rc_release" diff --git a/.github/workflows/pre-release-tests.yml b/.github/workflows/pre-release-tests.yml index 50b88b0b..c1f33723 100644 --- a/.github/workflows/pre-release-tests.yml +++ b/.github/workflows/pre-release-tests.yml @@ -3,33 +3,98 @@ name: Pre-Release Tests # Will only run for PRs and pushes to bump-meilisearch-v* on: - push: - branches: - - bump-meilisearch-v* - pull_request: - branches: - - bump-meilisearch-v* + push: + branches: + - bump-meilisearch-v* + pull_request: + branches: + - bump-meilisearch-v* jobs: - integration-tests: - runs-on: ubuntu-latest - strategy: - matrix: - php-versions: ['7.4', '8.0'] - name: integration-tests-against-rc (PHP ${{ matrix.php-versions }}) - steps: - - uses: actions/checkout@v3 - - name: Install PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - - name: Validate composer.json and composer.lock - run: composer validate - - name: Install dependencies - run: composer install --prefer-dist --no-progress --quiet - - name: Get the latest Meilisearch RC - run: echo "MEILISEARCH_VERSION=$(curl https://raw.githubusercontent.com/meilisearch/integration-guides/main/scripts/get-latest-meilisearch-rc.sh | bash)" >> $GITHUB_ENV - - name: Meilisearch (${{ env.MEILISEARCH_VERSION }}) setup with Docker - run: docker run -d -p 7700:7700 getmeili/meilisearch:${{ env.MEILISEARCH_VERSION }} meilisearch --master-key=masterKey --no-analytics - - name: Run test suite - run: composer test:unit + meilisearch-version: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Grep docker latest rc version of Meilisearch + id: grep-step + run: | + MEILISEARCH_VERSION=$(sh .github/scripts/get-latest-meilisearch-rc.sh) + echo $MEILISEARCH_VERSION + echo "version=$MEILISEARCH_VERSION" >> $GITHUB_OUTPUT + outputs: + version: ${{ steps.grep-step.outputs.version }} + + integration-tests: + runs-on: ubuntu-latest + needs: ['meilisearch-version'] + services: + meilisearch: + image: getmeili/meilisearch:${{ needs.meilisearch-version.outputs.version }} + ports: + - '7700:7700' + env: + MEILI_MASTER_KEY: masterKey + 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'] + 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' + + name: integration-tests-against-rc (PHP ${{ matrix.php-version }}) (Symfony ${{ matrix.sf-version }}.*) + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: composer, flex + + - 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' + + - name: Run test suite + run: composer test:unit -- --coverage-clover coverage.xml diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 477c50ff..20f2d83f 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -1,16 +1,16 @@ name: Release Drafter on: - push: - branches: - - main + push: + branches: + - main jobs: - update_release_draft: - runs-on: ubuntu-latest - steps: - - uses: release-drafter/release-drafter@v5 - with: - config-name: release-draft-template.yml - env: - GITHUB_TOKEN: ${{ secrets.RELEASE_DRAFTER_TOKEN }} + update_release_draft: + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v6 + with: + config-name: release-draft-template.yml + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_DRAFTER_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 18299d3f..4557343e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,6 +8,11 @@ on: - trying - staging - main + schedule: + - cron: '0 3 * * 1' + +env: + fail-fast: true jobs: integration-tests: @@ -15,61 +20,146 @@ jobs: # Will still run for each push to bump-meilisearch-v* if: github.event_name != 'pull_request' || !startsWith(github.base_ref, 'bump-meilisearch-v') runs-on: ubuntu-latest + services: + meilisearch: + image: getmeili/meilisearch:latest + ports: + - '7700:7700' + env: + MEILI_MASTER_KEY: masterKey + MEILI_NO_ANALYTICS: true strategy: matrix: - php-version: ['7.4', '8.0', '8.1', '8.2'] - include: + 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'] + exclude: - php-version: '7.4' - sf-version: '4.4.*' + sf-version: '6.4' - php-version: '7.4' - sf-version: '5.4.*' - - php-version: '8.0' - sf-version: '6.0.*' + 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: '6.0.*' + sf-version: '5.4' - php-version: '8.1' - sf-version: '6.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: '6.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' - name: integration-tests (PHP ${{ matrix.php-version }}) (Symfony ${{ matrix.sf-version }}) + name: integration-tests (PHP ${{ matrix.php-version }}) (Symfony ${{ matrix.sf-version }}.*) steps: - - uses: actions/checkout@v3 + - name: Checkout code + uses: actions/checkout@v5 + - name: Install PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - tools: composer:v2, flex + tools: composer, flex + - 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 }} - run: composer install --prefer-dist --no-progress --quiet - - name: Meilisearch setup with Docker - run: docker run -d -p 7700:7700 getmeili/meilisearch:latest meilisearch --master-key=masterKey --no-analytics + SYMFONY_REQUIRE: ${{ matrix.sf-version }}.* + with: + dependency-versions: 'highest' + - name: Run test suite - run: composer test:unit + run: composer test:unit -- --coverage-clover coverage.xml + + - name: Upload coverage file + uses: actions/upload-artifact@v4 + with: + name: 'phpunit-${{ matrix.php-version }}-${{ matrix.sf-version }}-coverage' + path: 'coverage.xml' code-style: runs-on: ubuntu-latest name: 'Code style' + env: + PHP_CS_FIXER_IGNORE_ENV: 1 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Install PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: 8.4 - name: Validate composer.json and composer.lock run: composer validate - name: Install dependencies - run: composer install --prefer-dist --no-progress --quiet + uses: ramsey/composer-install@v3 + env: + SYMFONY_REQUIRE: 7.2.* + with: + composer-options: '--no-progress --quiet' + dependency-versions: 'highest' - name: PHP CS Fixer run: composer lint:check - - name: PHPstan - run: composer phpstan + - name: PHP MD + run: composer phpmd + continue-on-error: true + + - name: PHPStan + run: | + vendor/bin/simple-phpunit --version + composer phpstan + + yaml-lint: + name: Yaml linting check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Yaml lint check + uses: ibiqlik/action-yamllint@v3 + with: + config_file: .yamllint.yml + + upload-coverage: + name: Upload coverage to Codecov + runs-on: ubuntu-latest + needs: + - integration-tests + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 2 + + - name: Download coverage files + uses: actions/download-artifact@v5 + with: + path: reports + + - name: Upload to Codecov + uses: codecov/codecov-action@v5 + with: + directory: reports diff --git a/.gitignore b/.gitignore index a0b80ceb..427199d6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ composer.lock /vendor/ /var/ +phpstan.neon # Meilisearch /data.ms/* diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 084c2808..de29a333 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -6,20 +6,23 @@ ->in(__DIR__.'/src') ->in(__DIR__.'/tests') ->append([__FILE__]); -$config = new PhpCsFixer\Config(); -$config->setRules([ - '@Symfony' => true, - '@PHP80Migration:risky' => true, - 'global_namespace_import' => [ - 'import_classes' => false, - 'import_functions' => false, - 'import_constants' => false, - ], - ] -) +return (new PhpCsFixer\Config()) + ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) ->setRiskyAllowed(true) ->setFinder($finder) -; - -return $config; + ->setRules([ + '@Symfony' => true, + '@PHP80Migration:risky' => true, + 'native_function_invocation' => ['include' => ['@compiler_optimized'], 'scope' => 'namespaced'], + 'global_namespace_import' => [ + 'import_classes' => false, + 'import_functions' => false, + 'import_constants' => false, + ], + 'no_superfluous_phpdoc_tags' => false, + // @todo: when we'll support only PHP 8.0 and upper, we can enable `parameters` for `trailing_comma_in_multiline` rule + 'trailing_comma_in_multiline' => ['after_heredoc' => true, 'elements' => ['array_destructuring', 'arrays', 'match'/* , 'parameters' */]], + // @todo: when we'll support only PHP 8.0 and upper, we can enable this + 'get_class_to_class_keyword' => false, + ]); diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 00000000..35c43988 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,9 @@ +extends: default +ignore: | + vendor +rules: + comments-indentation: disable + line-length: disable + document-start: disable + brackets: disable + truthy: disable diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 81a5b72f..5951e5bd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ First of all, thank you for contributing to Meilisearch! The goal of this docume 1. **You're familiar with [GitHub](https://github.com) and the [Pull Request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests) (PR) workflow.** 2. **You've read the Meilisearch [documentation](https://docs.meilisearch.com) and the [README](/README.md).** -3. **You know about the [Meilisearch community](https://docs.meilisearch.com/learn/what_is_meilisearch/contact.html). Please use this for help.** +3. **You know about the [Meilisearch community](https://www.meilisearch.com/docs/learn/what_is_meilisearch/contact.html). Please use this for help.** ## How to Contribute @@ -61,6 +61,8 @@ composer lint:check composer lint:fix # PHPstan composer phpstan +# PHPMD +composer phpmd ``` ### Using the Docker Environment diff --git a/LICENSE b/LICENSE index b1f0d22c..1ea8bbc9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-2022 Meili SAS +Copyright (c) 2019-2025 Meili SAS Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index af353ddf..1323a7d4 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,20 @@

Meilisearch | + Meilisearch Cloud | Documentation | Discord | Roadmap | Website | - FAQ + FAQ

+ Codecov coverage Latest Stable Version Test License - Bors enabled + Bors enabled

⚡ The Meilisearch bundle for Symfony

@@ -26,9 +28,10 @@ **Meilisearch** is an open-source search engine. [Discover what Meilisearch is!](https://github.com/meilisearch/meilisearch) -## Table of Contents +## Table of Contents - [📖 Documentation](#-documentation) +- [⚡ Supercharge your Meilisearch experience](#-supercharge-your-meilisearch-experience) - [📝 Requirements](#-requirements) - [🤖 Compatibility with Meilisearch](#-compatibility-with-meilisearch) - [💡 Learn More](#-learn-more) @@ -38,13 +41,19 @@ Check out the [Wiki](https://github.com/meilisearch/meilisearch-symfony/wiki) of this repository to get started! 🚀 -Also, see our [Documentation](https://docs.meilisearch.com/learn/tutorials/getting_started.html) or our [API References](https://docs.meilisearch.com/reference/api/). +Also, see our [Documentation](https://www.meilisearch.com/docs/learn/getting_started/installation) or our [API References](https://www.meilisearch.com/docs/reference/api/overview). + +## ⚡ Supercharge your Meilisearch experience + +Say goodbye to server deployment and manual updates with [Meilisearch Cloud](https://www.meilisearch.com/pricing?utm_campaign=oss&utm_source=integration&utm_medium=meilisearch-symfony). No credit card required. ## 📝 Requirements * **Require** PHP 7.4 and later. -* **Compatible** with Symfony 4.0 and later. -* **Support** Doctrine ORM and Doctrine MongoDB. +* **Compatible** with Symfony 5.4 and later. +* **Support** Doctrine ORM. + +For support of older versions, see older versions of this bundle. ## 🤖 Compatibility with Meilisearch @@ -54,10 +63,10 @@ This package guarantees compatibility with [version v1.x of Meilisearch](https:/ The following sections may interest you: -- **Manipulate documents**: see the [API references](https://docs.meilisearch.com/reference/api/documents.html) or read more about [documents](https://docs.meilisearch.com/learn/core_concepts/documents.html). -- **Search**: see the [API references](https://docs.meilisearch.com/reference/api/search.html) or follow our guide on [search parameters](https://docs.meilisearch.com/reference/features/search_parameters.html). -- **Manage the indexes**: see the [API references](https://docs.meilisearch.com/reference/api/indexes.html) or read more about [indexes](https://docs.meilisearch.com/learn/core_concepts/indexes.html). -- **Configure the index settings**: see the [API references](https://docs.meilisearch.com/reference/api/settings.html) or follow our guide on [settings parameters](https://docs.meilisearch.com/reference/features/settings.html). +- **Manipulate documents**: see the [API references](https://www.meilisearch.com/docs/reference/api/documents) or read more about [documents](https://www.meilisearch.com/docs/learn/core_concepts/documents). +- **Search**: see the [API references](https://www.meilisearch.com/docs/reference/api/search) or follow our guide on [search parameters](https://www.meilisearch.com/docs/reference/api/search#search-parameters). +- **Manage the indexes**: see the [API references](https://www.meilisearch.com/docs/reference/api/indexes) or read more about [indexes](https://www.meilisearch.com/docs/learn/core_concepts/indexes). +- **Configure the index settings**: see the [API references](https://www.meilisearch.com/docs/reference/api/settings) or follow our guide on [settings parameters](https://www.meilisearch.com/docs/reference/api/settings#settings_parameters). 📖 Also, check out the [Wiki](https://github.com/meilisearch/meilisearch-symfony/wiki) of this repository! diff --git a/bors.toml b/bors.toml index 330d47fe..0db50c48 100644 --- a/bors.toml +++ b/bors.toml @@ -1,8 +1,21 @@ status = [ 'integration-tests (PHP 7.4) (Symfony 5.4.*)', - 'integration-tests (PHP 8.0) (Symfony 6.0.*)', - 'integration-tests (PHP 8.1) (Symfony 6.1.*)', - 'integration-tests (PHP 8.2) (Symfony 6.2.*)', + 'integration-tests (PHP 8.1) (Symfony 6.4.*)', + 'integration-tests (PHP 8.2) (Symfony 6.4.*)', + 'integration-tests (PHP 8.2) (Symfony 7.0.*)', + 'integration-tests (PHP 8.2) (Symfony 7.1.*)', + 'integration-tests (PHP 8.2) (Symfony 7.2.*)', + 'integration-tests (PHP 8.2) (Symfony 7.3.*)', + 'integration-tests (PHP 8.3) (Symfony 6.4.*)', + 'integration-tests (PHP 8.3) (Symfony 7.0.*)', + 'integration-tests (PHP 8.3) (Symfony 7.1.*)', + 'integration-tests (PHP 8.3) (Symfony 7.2.*)', + 'integration-tests (PHP 8.3) (Symfony 7.3.*)', + 'integration-tests (PHP 8.4) (Symfony 6.4.*)', + 'integration-tests (PHP 8.4) (Symfony 7.0.*)', + 'integration-tests (PHP 8.4) (Symfony 7.1.*)', + 'integration-tests (PHP 8.4) (Symfony 7.2.*)', + 'integration-tests (PHP 8.4) (Symfony 7.3.*)', 'Code style' ] # 1 hour timeout diff --git a/composer.json b/composer.json index 7aa046b7..47c04655 100644 --- a/composer.json +++ b/composer.json @@ -20,29 +20,36 @@ "require": { "php": "^7.4|^8.0", "ext-json": "*", - "doctrine/doctrine-bundle": "^2.4", + "doctrine/doctrine-bundle": "^2.10", "meilisearch/meilisearch-php": "^1.0.0", - "symfony/filesystem": "^4.4 || ^5.0 || ^6.0", - "symfony/property-access": "^4.4 || ^5.0 || ^6.0", - "symfony/serializer": "^4.4 || ^5.0 || ^6.0" + "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" }, "require-dev": { - "doctrine/annotations": "^2.0", - "doctrine/orm": "^2.9", - "matthiasnoback/symfony-dependency-injection-test": "^4.3", - "nyholm/psr7": "^1.5.1", - "php-cs-fixer/shim": "^3.14", - "phpstan/extension-installer": "^1.2", - "phpstan/phpstan": "^1.10.6", - "phpstan/phpstan-doctrine": "^1.3.33", - "phpstan/phpstan-phpunit": "^1.3.10", - "phpstan/phpstan-symfony": "^1.2.23", - "phpunit/php-code-coverage": "^9.2.26", - "phpunit/phpunit": "^9.6.5", - "symfony/doctrine-bridge": "^4.4 || ^5.0 || ^6.0", - "symfony/http-client": "^4.4 || ^5.0 || ^6.0", - "symfony/phpunit-bridge": "^4.4 || ^5.0 || ^6.0", - "symfony/yaml": "^4.4 || ^5.0 || ^6.0" + "doctrine/annotations": "^2.0.0", + "doctrine/orm": "^2.12 || ^3.0", + "matthiasnoback/symfony-config-test": "^4.3 || ^5.2", + "matthiasnoback/symfony-dependency-injection-test": "^4.3 || ^5.0", + "nyholm/psr7": "^1.8.1", + "php-cs-fixer/shim": "^3.58.1", + "phpmd/phpmd": "^2.15", + "phpstan/extension-installer": "^1.4.1", + "phpstan/phpstan": "^1.11.4", + "phpstan/phpstan-doctrine": "^1.4.3", + "phpstan/phpstan-phpunit": "^1.4.0", + "phpstan/phpstan-symfony": "^1.4.4", + "phpunit/php-code-coverage": "^9.2.31", + "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 || ^6.0 || ^7.0", + "symfony/phpunit-bridge": "^6.4 || ^7.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0" }, "autoload": { "psr-4": { @@ -62,9 +69,10 @@ } }, "scripts": { - "phpstan": "./vendor/bin/phpstan --memory-limit=1G --ansi", - "test:unit": "./vendor/bin/phpunit --colors=always --verbose", - "test:unit:coverage": "XDEBUG_MODE=coverage ./vendor/bin/phpunit --colors=always --coverage-html=tests/coverage", + "phpmd": "./vendor/bin/phpmd src text phpmd.xml", + "phpstan": "./vendor/bin/phpstan", + "test:unit": "SYMFONY_DEPRECATIONS_HELPER='ignoreFile=./tests/baseline-ignore' ./vendor/bin/simple-phpunit --colors=always --verbose", + "test:unit:coverage": "SYMFONY_DEPRECATIONS_HELPER='ignoreFile=./tests/baseline-ignore' XDEBUG_MODE=coverage ./vendor/bin/simple-phpunit --colors=always --coverage-html=tests/coverage", "lint:check": "./vendor/bin/php-cs-fixer fix -v --using-cache=no --dry-run", "lint:fix": "./vendor/bin/php-cs-fixer fix -v --using-cache=no" } diff --git a/config/services.xml b/config/services.xml index d8d20c82..00fda01f 100644 --- a/config/services.xml +++ b/config/services.xml @@ -4,29 +4,90 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd"> - + + + + + + + + normalizer + + configuration + + + + The "%alias_id%" service alias is deprecated. Use "meilisearch.service" instead. + + + + + + + The "%alias_id%" service alias is deprecated. Use "meilisearch.search_indexer_subscriber" instead. + + + + url defined in MeilisearchExtension + api key defined in MeilisearchExtension + http client defined in MeilisearchExtension + null + client agents defined in MeilisearchExtension + null + + + The "%alias_id%" service alias is deprecated. Use "meilisearch.client" instead. + - + + + The "%alias_id%" service alias is deprecated. Use "meilisearch.client" instead. + + + + + + + + + + + + + + - - - - - + + + + + + - - %meili_url% - %meili_api_key% - - - %meili_symfony_version% - + + + - - + + + + + + + + + + + + + + + + + + + diff --git a/phpmd.baseline.xml b/phpmd.baseline.xml new file mode 100644 index 00000000..1e8da586 --- /dev/null +++ b/phpmd.baseline.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpmd.xml b/phpmd.xml new file mode 100644 index 00000000..ad3ff2c3 --- /dev/null +++ b/phpmd.xml @@ -0,0 +1,13 @@ + + + Ruleset for PHP Mess Detector that enforces coding standards + + + + + + + diff --git a/phpstan.neon b/phpstan.neon deleted file mode 100644 index b35f3d4e..00000000 --- a/phpstan.neon +++ /dev/null @@ -1,5 +0,0 @@ -parameters: - level: 5 - paths: - - src - - tests diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 00000000..28e5679a --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,9 @@ +parameters: + bootstrapFiles: + - vendor/bin/.phpunit/phpunit/vendor/autoload.php + level: 5 + paths: + - src + - tests + ignoreErrors: + - '#Call to static method getClass\(\) on an unknown class Doctrine\\Common\\Util\\ClassUtils#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e6554d3a..d50518cb 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ - + xsi:noNamespaceSchemaLocation="vendor/bin/.phpunit/phpunit/phpunit.xsd"> src/ @@ -14,8 +14,7 @@ - - + diff --git a/src/Collection.php b/src/Collection.php index c09e0d5c..2e0ef277 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -4,6 +4,12 @@ namespace Meilisearch\Bundle; +/** + * This class was extracted from illuminate. + * This will suppress all the PMD warnings in this class. + * + * @SuppressWarnings(PHPMD) + */ final class Collection implements \ArrayAccess, \Countable, \IteratorAggregate { /** @@ -11,9 +17,6 @@ final class Collection implements \ArrayAccess, \Countable, \IteratorAggregate */ private array $items; - /** - * @param mixed $items - */ public function __construct($items = []) { $this->items = $this->getArrayableItems($items); @@ -24,15 +27,9 @@ public function all(): array return $this->items; } - /** - * @param mixed $key - * @param mixed $default - * - * @return mixed - */ public function get($key, $default = null) { - if (array_key_exists($key, $this->items)) { + if (\array_key_exists($key, $this->items)) { return $this->items[$key]; } @@ -68,7 +65,7 @@ public function map(callable $callback) * * @return static */ - public function filter(callable $callback = null) + public function filter(?callable $callback = null) { if (null !== $callback) { return new self(array_filter($this->items, $callback, ARRAY_FILTER_USE_BOTH)); @@ -126,7 +123,7 @@ public function each(callable $callback) */ public function unique($key = null, bool $strict = false) { - if (is_null($key) && false === $strict) { + if (\is_null($key) && false === $strict) { return new self(array_unique($this->items, SORT_REGULAR)); } @@ -135,7 +132,7 @@ public function unique($key = null, bool $strict = false) $exists = []; return $this->reject(function ($item, $key) use ($callback, $strict, &$exists) { - if (in_array($id = $callback($item, $key), $exists, $strict)) { + if (\in_array($id = $callback($item, $key), $exists, $strict)) { return true; } @@ -145,14 +142,10 @@ public function unique($key = null, bool $strict = false) /** * Get the first item from the collection passing the given truth test. - * - * @param mixed $default - * - * @return mixed */ - public function first(callable $callback = null, $default = null) + public function first(?callable $callback = null, $default = null) { - if (is_null($callback)) { + if (\is_null($callback)) { if (empty($this->items)) { return $default instanceof \Closure ? $default() : $default; } @@ -175,27 +168,18 @@ public function first(callable $callback = null, $default = null) * Get the first item by the given key value pair. * * @param string $key - * @param mixed $operator - * @param mixed $value - * - * @return mixed */ public function firstWhere($key, $operator = null, $value = null) { - return $this->first($this->operatorForWhere(...func_get_args())); + return $this->first($this->operatorForWhere(...\func_get_args())); } - /** - * @param mixed $offset - */ public function offsetExists($offset): bool { return isset($this->items[$offset]); } /** - * @param mixed $offset - * * @return mixed */ #[\ReturnTypeWillChange] @@ -204,22 +188,15 @@ public function offsetGet($offset) return $this->items[$offset]; } - /** - * @param mixed $offset - * @param mixed $value - */ public function offsetSet($offset, $value): void { - if (is_null($offset)) { + if (\is_null($offset)) { $this->items[] = $value; } else { $this->items[$offset] = $value; } } - /** - * @param mixed $offset - */ public function offsetUnset($offset): void { unset($this->items[$offset]); @@ -227,7 +204,7 @@ public function offsetUnset($offset): void public function count(): int { - return count($this->items); + return \count($this->items); } public function getIterator(): \ArrayIterator @@ -235,12 +212,9 @@ public function getIterator(): \ArrayIterator return new \ArrayIterator($this->items); } - /** - * @param mixed $items - */ private function getArrayableItems($items): array { - if (is_array($items)) { + if (\is_array($items)) { return $items; } @@ -263,12 +237,9 @@ private function getArrayableItems($items): array return (array) $items; } - /** - * @param mixed $value - */ private function useAsCallable($value): bool { - return !is_string($value) && is_callable($value); + return !\is_string($value) && \is_callable($value); } /** @@ -286,31 +257,27 @@ private function valueRetriever($value) } /** - * @param mixed $target * @param string|array|int|null $key - * @param mixed $default - * - * @return mixed */ private static function getDeepData($target, $key, $default = null) { - if (is_null($key)) { + if (\is_null($key)) { return $target; } - $key = is_array($key) ? $key : explode('.', $key); + $key = \is_array($key) ? $key : explode('.', $key); foreach ($key as $i => $segment) { unset($key[$i]); - if (is_null($segment)) { + if (\is_null($segment)) { return $target; } if ('*' === $segment) { if ($target instanceof self) { $target = $target->all(); - } elseif (!is_array($target)) { + } elseif (!\is_array($target)) { return $default instanceof \Closure ? $default() : $default; } @@ -320,12 +287,12 @@ private static function getDeepData($target, $key, $default = null) $result[] = self::getDeepData($item, $key); } - return in_array('*', $key, true) ? self::arrayCollapse($result) : $result; + return \in_array('*', $key, true) ? self::arrayCollapse($result) : $result; } if (self::accessible($target) && self::existsInArray($target, $segment)) { $target = $target[$segment]; - } elseif (is_object($target) && isset($target->{$segment})) { + } elseif (\is_object($target) && isset($target->{$segment})) { $target = $target->{$segment}; } else { return $default instanceof \Closure ? $default() : $default; @@ -342,7 +309,7 @@ public static function arrayCollapse(iterable $array): array foreach ($array as $values) { if ($values instanceof self) { $values = $values->all(); - } elseif (!is_array($values)) { + } elseif (!\is_array($values)) { continue; } @@ -352,12 +319,9 @@ public static function arrayCollapse(iterable $array): array return array_merge([], ...$results); } - /** - * @param mixed $value - */ public static function accessible($value): bool { - return is_array($value) || $value instanceof \ArrayAccess; + return \is_array($value) || $value instanceof \ArrayAccess; } /** @@ -372,21 +336,18 @@ private static function existsInArray($array, $key): bool return $array->offsetExists($key); } - return array_key_exists($key, $array); + return \array_key_exists($key, $array); } - /** - * @param mixed $value - */ private function operatorForWhere(string $key, ?string $operator = null, $value = null): \Closure { - if (1 === func_num_args()) { + if (1 === \func_num_args()) { $value = true; $operator = '='; } - if (2 === func_num_args()) { + if (2 === \func_num_args()) { $value = $operator; $operator = '='; @@ -395,10 +356,10 @@ private function operatorForWhere(string $key, ?string $operator = null, $value return static function ($item) use ($key, $operator, $value) { $retrieved = self::getDeepData($item, $key); - $strings = array_filter([$retrieved, $value], fn ($value) => is_string($value) || (is_object($value) && method_exists($value, '__toString'))); + $strings = array_filter([$retrieved, $value], fn ($value) => \is_string($value) || (\is_object($value) && method_exists($value, '__toString'))); - if (count($strings) < 2 && 1 === count(array_filter([$retrieved, $value], 'is_object'))) { - return in_array($operator, ['!=', '<>', '!==']); + if (\count($strings) < 2 && 1 === \count(array_filter([$retrieved, $value], 'is_object'))) { + return \in_array($operator, ['!=', '<>', '!==']); } switch ($operator) { diff --git a/src/Command/IndexCommand.php b/src/Command/IndexCommand.php index 5b54d373..8260b12e 100644 --- a/src/Command/IndexCommand.php +++ b/src/Command/IndexCommand.php @@ -10,14 +10,14 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -/** - * Class IndexCommand. - */ abstract class IndexCommand extends Command { - private string $prefix; + protected const DEFAULT_RESPONSE_TIMEOUT = 5000; + protected SearchService $searchService; + private string $prefix; + public function __construct(SearchService $searchService) { $this->searchService = $searchService; @@ -26,23 +26,13 @@ public function __construct(SearchService $searchService) parent::__construct(); } - protected function getIndices(): Collection - { - return (new Collection($this->searchService->getConfiguration()->get('indices'))) - ->transform(function (array $item) { - $item['name'] = $this->prefix.$item['name']; - - return $item; - }); - } - protected function getEntitiesFromArgs(InputInterface $input, OutputInterface $output): Collection { - $indices = $this->getIndices(); + $indices = new Collection($this->searchService->getConfiguration()->get('indices')); $indexNames = new Collection(); if ($indexList = $input->getOption('indices')) { - $list = \explode(',', $indexList); + $list = explode(',', $indexList); $indexNames = (new Collection($list))->transform(function (string $item): string { // Check if the given index name already contains the prefix if (!str_contains($item, $this->prefix)) { @@ -53,7 +43,7 @@ protected function getEntitiesFromArgs(InputInterface $input, OutputInterface $o }); } - if (0 === count($indexNames) && 0 === count($indices)) { + if (0 === \count($indexNames) && 0 === \count($indices)) { $output->writeln( 'No indices specified. Please either specify indices using the cli option or YAML configuration.' ); @@ -61,8 +51,8 @@ protected function getEntitiesFromArgs(InputInterface $input, OutputInterface $o return new Collection(); } - if (count($indexNames) > 0) { - return $indices->reject(fn (array $item) => !in_array($item['name'], $indexNames->all(), true)); + if (\count($indexNames) > 0) { + return $indices->reject(fn (array $item) => !\in_array($item['prefixed_name'], $indexNames->all(), true)); } return $indices; diff --git a/src/Command/MeilisearchClearCommand.php b/src/Command/MeilisearchClearCommand.php index 2cb8330b..6de03ccf 100644 --- a/src/Command/MeilisearchClearCommand.php +++ b/src/Command/MeilisearchClearCommand.php @@ -4,29 +4,17 @@ namespace Meilisearch\Bundle\Command; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -/** - * Class MeilisearchClearCommand. - */ +#[AsCommand(name: 'meilisearch:clear', description: 'Clear the index documents', aliases: ['meili:clear'])] final class MeilisearchClearCommand extends IndexCommand { - public static function getDefaultName(): string - { - return 'meili:clear'; - } - - public static function getDefaultDescription(): string - { - return 'Clear the index documents'; - } - protected function configure(): void { $this - ->setDescription(self::getDefaultDescription()) ->addOption('indices', 'i', InputOption::VALUE_OPTIONAL, 'Comma-separated list of index names'); } @@ -36,17 +24,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** @var array $index */ foreach ($indexToClear as $index) { - $indexName = $index['name']; + $indexName = $index['prefixed_name']; $className = $index['class']; + $msg = "Cleared $indexName index of $className"; $array = $this->searchService->clear($className); + if ('failed' === $array['status']) { - $output->writeln('Index '.$indexName.' couldn\'t be cleared'); - } else { - $output->writeln('Cleared '.$indexName.' index of '.$className.''); + $msg = "Index $indexName couldn\'t be cleared"; } + + $output->writeln($msg); } - if (0 === count($indexToClear)) { + if (0 === \count($indexToClear)) { $output->writeln('Cannot clear index. Not found.'); } diff --git a/src/Command/MeilisearchCreateCommand.php b/src/Command/MeilisearchCreateCommand.php index 86fa1ac9..7058ab4d 100644 --- a/src/Command/MeilisearchCreateCommand.php +++ b/src/Command/MeilisearchCreateCommand.php @@ -5,63 +5,63 @@ namespace Meilisearch\Bundle\Command; use Meilisearch\Bundle\Collection; -use Meilisearch\Bundle\Exception\InvalidSettingName; -use Meilisearch\Bundle\Exception\TaskException; +use Meilisearch\Bundle\EventListener\ConsoleOutputSubscriber; use Meilisearch\Bundle\Model\Aggregator; use Meilisearch\Bundle\SearchService; +use Meilisearch\Bundle\Services\SettingsUpdater; use Meilisearch\Client; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +#[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) + public function __construct(SearchService $searchService, Client $searchClient, SettingsUpdater $settingsUpdater, EventDispatcherInterface $eventDispatcher) { parent::__construct($searchService); $this->searchClient = $searchClient; - } - - public static function getDefaultName(): string - { - return 'meili:create'; - } - - public static function getDefaultDescription(): string - { - return 'Create indexes'; + $this->settingsUpdater = $settingsUpdater; + $this->eventDispatcher = $eventDispatcher; } protected function configure(): void { $this - ->setDescription(self::getDefaultDescription()) - ->addOption('indices', 'i', InputOption::VALUE_OPTIONAL, 'Comma-separated list of index names'); + ->addOption('indices', 'i', InputOption::VALUE_OPTIONAL, 'Comma-separated list of index names') + ->addOption( + 'update-settings', + null, + InputOption::VALUE_NEGATABLE, + 'Update settings related to indices to the search engine', + true + ) + ->addOption( + 'response-timeout', + 't', + InputOption::VALUE_REQUIRED, + 'Timeout (in ms) to get response from the search engine', + self::DEFAULT_RESPONSE_TIMEOUT + ) + ; } protected function execute(InputInterface $input, OutputInterface $output): int { - $indexes = $this->getEntitiesFromArgs($input, $output); + $this->eventDispatcher->addSubscriber(new ConsoleOutputSubscriber(new SymfonyStyle($input, $output))); - foreach ($indexes as $key => $index) { - $entityClassName = $index['class']; - if (is_subclass_of($entityClassName, Aggregator::class)) { - $indexes->forget($key); - - $indexes = new Collection(array_merge( - $indexes->all(), - array_map( - static fn ($entity) => ['name' => $index['name'], 'class' => $entity], - $entityClassName::getEntities() - ) - )); - } - } - - $entitiesToIndex = array_unique($indexes->all(), SORT_REGULAR); + $indexes = $this->getEntitiesFromArgs($input, $output); + $entitiesToIndex = $this->entitiesToIndex($indexes); + $updateSettings = $input->getOption('update-settings'); + $responseTimeout = ((int) $input->getOption('response-timeout')) ?: self::DEFAULT_RESPONSE_TIMEOUT; /** @var array $index */ foreach ($entitiesToIndex as $index) { @@ -71,27 +71,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int continue; } - $output->writeln('Creating index '.$index['name'].' for '.$entityClassName.''); - - $task = $this->searchClient->createIndex($index['name']); - $this->searchClient->waitForTask($task['taskUid']); - $indexInstance = $this->searchClient->index($index['name']); + $indexName = $index['prefixed_name']; - if (isset($index['settings']) && is_array($index['settings'])) { - foreach ($index['settings'] as $variable => $value) { - $method = sprintf('update%s', ucfirst($variable)); + $output->writeln('Creating index '.$indexName.' for '.$entityClassName.''); - if (false === method_exists($indexInstance, $method)) { - throw new InvalidSettingName(sprintf('Invalid setting name: "%s"', $variable)); - } + $task = $this->searchClient->createIndex($indexName); + $this->searchClient->waitForTask($task['taskUid'], $responseTimeout); - $task = $indexInstance->{$method}($value); - $task = $indexInstance->getTask($task['taskUid']); - - if ('failed' === $task['status']) { - throw new TaskException($task['error']); - } - } + if ($updateSettings) { + $this->settingsUpdater->update($indexName, $responseTimeout); } } @@ -99,4 +87,27 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } + + private function entitiesToIndex(Collection $indexes): array + { + foreach ($indexes as $key => $index) { + $entityClassName = $index['class']; + + if (!is_subclass_of($entityClassName, Aggregator::class)) { + continue; + } + + $indexes->forget($key); + + $indexes = new Collection(array_merge( + $indexes->all(), + array_map( + static fn ($entity) => ['name' => $index['name'], 'prefixed_name' => $index['prefixed_name'], 'class' => $entity], + $entityClassName::getEntities() + ) + )); + } + + return array_unique($indexes->all(), SORT_REGULAR); + } } diff --git a/src/Command/MeilisearchDeleteCommand.php b/src/Command/MeilisearchDeleteCommand.php index a316ed19..bae54e53 100644 --- a/src/Command/MeilisearchDeleteCommand.php +++ b/src/Command/MeilisearchDeleteCommand.php @@ -6,29 +6,17 @@ use Meilisearch\Bundle\Collection; use Meilisearch\Exceptions\ApiException; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -/** - * Class MeilisearchDeleteCommand. - */ +#[AsCommand(name: 'meilisearch:delete', description: 'Delete the indexes', aliases: ['meili:delete'])] final class MeilisearchDeleteCommand extends IndexCommand { - public static function getDefaultName(): string - { - return 'meili:delete'; - } - - public static function getDefaultDescription(): string - { - return 'Delete the indexes'; - } - protected function configure(): void { $this - ->setDescription(self::getDefaultDescription()) ->addOption('indices', 'i', InputOption::VALUE_OPTIONAL, 'Comma-separated list of index names'); } @@ -38,17 +26,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** @var array $index */ foreach ($indexToDelete as $index) { - $indexName = $index['name']; + $indexName = $index['prefixed_name']; + try { $this->searchService->deleteByIndexName($indexName); } catch (ApiException $e) { $output->writeln('Cannot delete '.$indexName.': '.$e->getMessage()); + continue; } + $output->writeln('Deleted '.$indexName.''); } - if (0 === count($indexToDelete)) { + if (0 === \count($indexToDelete)) { $output->writeln('Cannot delete index. Not found.'); } diff --git a/src/Command/MeilisearchImportCommand.php b/src/Command/MeilisearchImportCommand.php index 1f97c661..37da172e 100644 --- a/src/Command/MeilisearchImportCommand.php +++ b/src/Command/MeilisearchImportCommand.php @@ -6,55 +6,59 @@ use Doctrine\Persistence\ManagerRegistry; use Meilisearch\Bundle\Collection; -use Meilisearch\Bundle\Exception\InvalidSettingName; +use Meilisearch\Bundle\EventListener\ConsoleOutputSubscriber; use Meilisearch\Bundle\Exception\TaskException; use Meilisearch\Bundle\Model\Aggregator; use Meilisearch\Bundle\SearchService; +use Meilisearch\Bundle\Services\SettingsUpdater; use Meilisearch\Client; +use Meilisearch\Exceptions\TimeOutException; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; -/** - * Class MeilisearchImportCommand. - */ +#[AsCommand(name: 'meilisearch:import', description: 'Import given entity into search engine', aliases: ['meili:import'])] final class MeilisearchImportCommand extends IndexCommand { - private const DEFAULT_RESPONSE_TIMEOUT = 5000; + private const TEMP_INDEX_PREFIX = '_tmp_'; - protected Client $searchClient; - protected ManagerRegistry $managerRegistry; + private Client $searchClient; + private ManagerRegistry $managerRegistry; + private SettingsUpdater $settingsUpdater; + private EventDispatcherInterface $eventDispatcher; - public function __construct(SearchService $searchService, ManagerRegistry $managerRegistry, Client $searchClient) + public function __construct(SearchService $searchService, ManagerRegistry $managerRegistry, Client $searchClient, SettingsUpdater $settingsUpdater, EventDispatcherInterface $eventDispatcher) { parent::__construct($searchService); $this->managerRegistry = $managerRegistry; $this->searchClient = $searchClient; - } - - public static function getDefaultName(): string - { - return 'meili:import'; - } - - public static function getDefaultDescription(): string - { - return 'Import given entity into search engine'; + $this->settingsUpdater = $settingsUpdater; + $this->eventDispatcher = $eventDispatcher; } protected function configure(): void { $this - ->setDescription(self::getDefaultDescription()) ->addOption('indices', 'i', InputOption::VALUE_OPTIONAL, 'Comma-separated list of index names') ->addOption( 'update-settings', null, - InputOption::VALUE_NONE, - 'Update settings related to indices to the search engine' + InputOption::VALUE_NEGATABLE, + 'Update settings related to indices to the search engine', + true ) ->addOption('batch-size', null, InputOption::VALUE_REQUIRED) + ->addOption( + 'skip-batches', + null, + InputOption::VALUE_REQUIRED, + 'Skip the first N batches and start importing from the N+1 batch', + 0 + ) ->addOption( 'response-timeout', 't', @@ -62,105 +66,113 @@ protected function configure(): void 'Timeout (in ms) to get response from the search engine', self::DEFAULT_RESPONSE_TIMEOUT ) + ->addOption( + 'swap-indices', + null, + InputOption::VALUE_NONE, + 'Import to temporary indices and use index swap to prevent downtime' + ) ; } protected function execute(InputInterface $input, OutputInterface $output): int { + $this->eventDispatcher->addSubscriber(new ConsoleOutputSubscriber(new SymfonyStyle($input, $output))); + $indexes = $this->getEntitiesFromArgs($input, $output); + $entitiesToIndex = $this->entitiesToIndex($indexes); $config = $this->searchService->getConfiguration(); + $swapIndices = $input->getOption('swap-indices'); + $initialPrefix = $config['prefix'] ?? ''; + $prefix = null; - foreach ($indexes as $key => $index) { - $entityClassName = $index['class']; - if (is_subclass_of($entityClassName, Aggregator::class)) { - $indexes->forget($key); - - $indexes = new Collection(array_merge( - $indexes->all(), - array_map( - fn ($entity) => ['class' => $entity], - $entityClassName::getEntities() - ) - )); - } + if ($swapIndices) { + $prefix = self::TEMP_INDEX_PREFIX; + $config['prefix'] = $prefix.($config['prefix'] ?? ''); } - $entitiesToIndex = array_unique($indexes->all(), SORT_REGULAR); - $batchSize = $input->getOption('batch-size'); + $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)) { 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.''); - $page = 0; + if ($updateSettings) { + $this->settingsUpdater->update($index['prefixed_name'], $responseTimeout, $prefix ? $prefix.$index['prefixed_name'] : null); + } + + $page = max(0, (int) $input->getOption('skip-batches')); + + if ($page > 0) { + $output->writeln( + \sprintf( + 'Skipping first %d batches (%d records)', + $page, + $page * $batchSize, + ) + ); + } + do { $entities = $repository->findBy( [], - null, + $sortByAttrs, $batchSize, $batchSize * $page ); $responses = $this->formatIndexingResponse($this->searchService->index($manager, $entities), $responseTimeout); + $totalIndexed += \count($entities); foreach ($responses as $indexName => $numberOfRecords) { $output->writeln( - sprintf( - 'Indexed %s / %s %s entities into %s index', + \sprintf( + 'Indexed a batch of %d / %d %s entities into %s index (%d indexed since start)', $numberOfRecords, - count($entities), + \count($entities), $entityClassName, - ''.$indexName.'' + ''.$indexName.'', + $totalIndexed, ) ); } - if (isset($index['settings']) - && is_array($index['settings']) - && count($index['settings']) > 0) { - $indexInstance = $this->searchClient->index($index['name']); - foreach ($index['settings'] as $variable => $value) { - $method = sprintf('update%s', ucfirst($variable)); - if (false === method_exists($indexInstance, $method)) { - throw new InvalidSettingName(sprintf('Invalid setting name: "%s"', $variable)); - } - - // Update - $task = $indexInstance->{$method}($value); - - // Get task information using uid - $indexInstance->waitForTask($task['taskUid'], $responseTimeout); - $task = $indexInstance->getTask($task['taskUid']); - - if ('failed' === $task['status']) { - throw new TaskException($task['error']); - } else { - $output->writeln('Settings updated.'); - } - } - } + $manager->clear(); ++$page; - } while (count($entities) >= $batchSize); + } while (\count($entities) >= $batchSize); $manager->clear(); } + if ($swapIndices) { + $this->swapIndices($indexes, $prefix, $output); + + $config['prefix'] = $initialPrefix; + } + $output->writeln('Done!'); return 0; } - /* + /** * @throws TimeOutException */ private function formatIndexingResponse(array $batch, int $responseTimeout): array @@ -169,7 +181,7 @@ private function formatIndexingResponse(array $batch, int $responseTimeout): arr foreach ($batch as $chunk) { foreach ($chunk as $indexName => $apiResponse) { - if (!array_key_exists($indexName, $formattedResponse)) { + if (!\array_key_exists($indexName, $formattedResponse)) { $formattedResponse[$indexName] = 0; } @@ -180,7 +192,7 @@ private function formatIndexingResponse(array $batch, int $responseTimeout): arr $task = $indexInstance->getTask($apiResponse['taskUid']); if ('failed' === $task['status']) { - throw new TaskException($task['error']); + throw new TaskException($task['error']['message']); } $formattedResponse[$indexName] += $task['details']['indexedDocuments']; @@ -189,4 +201,55 @@ private function formatIndexingResponse(array $batch, int $responseTimeout): arr return $formattedResponse; } + + private function entitiesToIndex(Collection $indexes): array + { + foreach ($indexes as $key => $index) { + $entityClassName = $index['class']; + + if (!is_subclass_of($entityClassName, Aggregator::class)) { + continue; + } + + $indexes->forget($key); + + $indexes = new Collection(array_merge( + $indexes->all(), + array_map( + static fn ($entity) => ['name' => $index['name'], 'prefixed_name' => $index['prefixed_name'], 'class' => $entity], + $entityClassName::getEntities() + ) + )); + } + + return array_unique($indexes->all(), SORT_REGULAR); + } + + private function swapIndices(Collection $indexes, string $prefix, OutputInterface $output): void + { + $indexPairs = []; + + foreach ($indexes as $index) { + $tempIndex = $index; + $tempIndex['name'] = $prefix.$tempIndex['prefixed_name']; + $pair = [$tempIndex['name'], $index['prefixed_name']]; + + // Indexes must be declared only once during a swap + if (!\in_array($pair, $indexPairs, true)) { + $indexPairs[] = $pair; + } + } + + // swap indexes + $output->writeln('Swapping indices...'); + $this->searchClient->swapIndexes($indexPairs); + $output->writeln('Indices swapped.'); + $output->writeln('Deleting temporary indices...'); + + // delete temp indexes + foreach ($indexPairs as $pair) { + $this->searchService->deleteByIndexName($pair[0]); + $output->writeln('Deleted '.$pair[0].''); + } + } } diff --git a/src/Command/MeilisearchUpdateSettingsCommand.php b/src/Command/MeilisearchUpdateSettingsCommand.php new file mode 100644 index 00000000..a91fdcf6 --- /dev/null +++ b/src/Command/MeilisearchUpdateSettingsCommand.php @@ -0,0 +1,93 @@ +settingsUpdater = $settingsUpdater; + $this->eventDispatcher = $eventDispatcher; + } + + protected function configure(): void + { + $this + ->addOption('indices', 'i', InputOption::VALUE_OPTIONAL, 'Comma-separated list of index names') + ->addOption( + 'response-timeout', + 't', + InputOption::VALUE_REQUIRED, + 'Timeout (in ms) to get response from the search engine', + self::DEFAULT_RESPONSE_TIMEOUT + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->eventDispatcher->addSubscriber(new ConsoleOutputSubscriber(new SymfonyStyle($input, $output))); + + $indexes = $this->getEntitiesFromArgs($input, $output); + $entitiesToIndex = $this->entitiesToIndex($indexes); + $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)) { + continue; + } + + $this->settingsUpdater->update($index['prefixed_name'], $responseTimeout); + } + + $output->writeln('Done!'); + + return 0; + } + + private function entitiesToIndex(Collection $indexes): array + { + foreach ($indexes as $key => $index) { + $entityClassName = $index['class']; + + if (!is_subclass_of($entityClassName, Aggregator::class)) { + continue; + } + + $indexes->forget($key); + + $indexes = new Collection(array_merge( + $indexes->all(), + array_map( + static fn ($entity) => ['name' => $index['name'], 'prefixed_name' => $index['prefixed_name'], 'class' => $entity], + $entityClassName::getEntities() + ) + )); + } + + return array_unique($indexes->all(), SORT_REGULAR); + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 0ce0f6e7..97dd0f84 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -4,27 +4,29 @@ namespace Meilisearch\Bundle\DependencyInjection; +use Meilisearch\Bundle\Searchable; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; final class Configuration implements ConfigurationInterface { public function getConfigTreeBuilder(): TreeBuilder { - $treeBuilder = new TreeBuilder('meili_search'); + $treeBuilder = new TreeBuilder('meilisearch'); $rootNode = $treeBuilder->getRootNode(); $rootNode ->children() - ->scalarNode('url')->end() + ->scalarNode('url')->defaultValue('http://localhost:7700')->end() ->scalarNode('api_key')->end() ->scalarNode('prefix') - ->defaultValue(null) + ->defaultNull() ->end() - ->scalarNode('nbResults') + ->integerNode('nbResults') ->defaultValue(20) ->end() - ->scalarNode('batchSize') + ->integerNode('batchSize') ->defaultValue(500) ->end() ->arrayNode('doctrineSubscribedEvents') @@ -34,6 +36,9 @@ public function getConfigTreeBuilder(): TreeBuilder ->scalarNode('serializer') ->defaultValue('serializer') ->end() + ->scalarNode('http_client') + ->defaultValue('psr18.http_client') + ->end() ->arrayNode('indices') ->arrayPrototype() ->children() @@ -49,14 +54,40 @@ public function getConfigTreeBuilder(): TreeBuilder ->info('When set to true, it will call normalize method with an extra groups parameter "groups" => [Searchable::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]) + ->scalarPrototype()->end() + ->end() ->scalarNode('index_if') ->info('Property accessor path (like method or property name) used to decide if an entry should be indexed.') ->defaultNull() ->end() - ->arrayNode('settings') - ->info('Configure indices settings, see: https://docs.meilisearch.com/guides/advanced_guides/settings.html') - ->arrayPrototype() - ->variablePrototype()->end() + ->variableNode('settings') + ->defaultValue([]) + ->info('Configure indices settings, see: https://www.meilisearch.com/docs/reference/api/settings') + ->beforeNormalization() + ->always() + ->then(static function ($value) { + if (null === $value) { + return []; + } + + if (!\is_array($value)) { + throw new InvalidConfigurationException('Settings must be an array.'); + } + + $stringSettings = ['distinctAttribute', 'proximityPrecision', 'searchCutoffMs']; + + foreach ($stringSettings as $setting) { + if (isset($value[$setting]) && !\is_array($value[$setting])) { + $value[$setting] = (array) $value[$setting]; + } + } + + return $value; + }) + ->end() ->end() ->end() ->end() diff --git a/src/DependencyInjection/MeilisearchExtension.php b/src/DependencyInjection/MeilisearchExtension.php index ed1ce5c5..2ffcc300 100644 --- a/src/DependencyInjection/MeilisearchExtension.php +++ b/src/DependencyInjection/MeilisearchExtension.php @@ -4,24 +4,18 @@ namespace Meilisearch\Bundle\DependencyInjection; -use Meilisearch\Bundle\Engine; use Meilisearch\Bundle\MeilisearchBundle; -use Meilisearch\Bundle\Services\MeilisearchService; +use Meilisearch\Bundle\Services\UnixTimestampNormalizer; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Loader; use Symfony\Component\DependencyInjection\Reference; -use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\HttpKernel\Kernel; -/** - * Class MeilisearchExtension. - */ final class MeilisearchExtension extends Extension { - /** - * {@inheritdoc} - */ public function load(array $configs, ContainerBuilder $container): void { $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__.'/../../config')); @@ -34,23 +28,56 @@ public function load(array $configs, ContainerBuilder $container): void $config['prefix'] = $container->getParameter('kernel.environment').'_'; } + 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()); - if (\count($doctrineSubscribedEvents = $config['doctrineSubscribedEvents']) > 0) { - $container->getDefinition('search.search_indexer_subscriber')->setArgument(1, $doctrineSubscribedEvents); + if (\count($doctrineEvents = $config['doctrineSubscribedEvents']) > 0) { + $subscriber = $container->getDefinition('meilisearch.search_indexer_subscriber'); + + foreach ($doctrineEvents as $event) { + $subscriber->addTag('doctrine.event_listener', ['event' => $event]); + $subscriber->addTag('doctrine_mongodb.odm.event_listener', ['event' => $event]); + } } else { - $container->removeDefinition('search.search_indexer_subscriber'); + $container->removeDefinition('meilisearch.search_indexer_subscriber'); } - $engineDefinition = new Definition(Engine::class, [new Reference('search.client')]); + $container->findDefinition('meilisearch.client') + ->replaceArgument(0, $config['url']) + ->replaceArgument(1, $config['api_key']) + ->replaceArgument(2, new Reference($config['http_client'], ContainerInterface::IGNORE_ON_INVALID_REFERENCE)) + ->replaceArgument(4, [MeilisearchBundle::qualifiedVersion()]); + + $container->findDefinition('meilisearch.service') + ->replaceArgument(0, new Reference($config['serializer'])) + ->replaceArgument(2, $config); - $searchServiceDefinition = (new Definition( - MeilisearchService::class, - [new Reference($config['serializer']), $engineDefinition, $config] - )); + if (Kernel::VERSION_ID >= 70100) { + $container->removeDefinition(UnixTimestampNormalizer::class); + } + } + + /** + * @param array $settings + * + * @return array + */ + private function findReferences(array $settings): array + { + foreach ($settings as $key => $value) { + if (\is_array($value)) { + $settings[$key] = $this->findReferences($value); + } elseif ('_service' === substr((string) $key, -8) || str_starts_with((string) $value, '@') || 'service' === $key) { + $settings[$key] = new Reference(ltrim($value, '@')); + } + } - $container->setDefinition('search.service', $searchServiceDefinition->setPublic(true)); + return $settings; } } diff --git a/src/Document/Aggregator.php b/src/Document/Aggregator.php index 0f45cdf7..336e3bcb 100644 --- a/src/Document/Aggregator.php +++ b/src/Document/Aggregator.php @@ -6,9 +6,6 @@ use Meilisearch\Bundle\Model\Aggregator as BaseAggregator; -/** - * Class Aggregator. - */ abstract class Aggregator extends BaseAggregator { } diff --git a/src/Engine.php b/src/Engine.php index 9fb960ed..50b54528 100644 --- a/src/Engine.php +++ b/src/Engine.php @@ -7,9 +7,6 @@ use Meilisearch\Client; use Meilisearch\Exceptions\ApiException; -/** - * Class Engine. - */ final class Engine { private Client $client; @@ -24,21 +21,20 @@ 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 array|SearchableEntity $searchableEntities * * @throws ApiException */ public function index($searchableEntities): array { if ($searchableEntities instanceof SearchableEntity) { - /** @var SearchableEntity[] $searchableEntities */ $searchableEntities = [$searchableEntities]; } $data = []; foreach ($searchableEntities as $entity) { $searchableArray = $entity->getSearchableArray(); - if (null === $searchableArray || 0 === \count($searchableArray)) { + if ([] === $searchableArray) { continue; } @@ -65,23 +61,17 @@ 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 array|SearchableEntity $searchableEntities */ public function remove($searchableEntities): array { if ($searchableEntities instanceof SearchableEntity) { - /** @var SearchableEntity[] $searchableEntities */ $searchableEntities = [$searchableEntities]; } $data = []; - /** @var SearchableEntity $entity */ foreach ($searchableEntities as $entity) { - $searchableArray = $entity->getSearchableArray(); - if (0 === \count($searchableArray)) { - continue; - } $indexUid = $entity->getIndexUid(); if (!isset($data[$indexUid])) { @@ -93,9 +83,12 @@ public function remove($searchableEntities): array $result = []; foreach ($data as $indexUid => $objects) { - $result[$indexUid] = $this->client - ->index($indexUid) - ->deleteDocument(reset($objects)); + $result[$indexUid] = []; + foreach ($objects as $object) { + $result[$indexUid][] = $this->client + ->index($indexUid) + ->deleteDocument($object); + } } return $result; @@ -104,16 +97,15 @@ public function remove($searchableEntities): array /** * Clear the records of an index. * This method enables you to delete an index’s contents (records). - * Will fail if the index does not exists. + * Will fail if the index does not exist. * * @throws ApiException */ public function clear(string $indexUid): array { $index = $this->client->index($indexUid); - $task = $index->deleteAllDocuments(); - return $task; + return $index->deleteAllDocuments(); } /** @@ -129,11 +121,7 @@ public function delete(string $indexUid): ?array */ public function search(string $query, string $indexUid, array $searchParams): array { - if ('' === $query) { - $query = null; - } - - return $this->client->index($indexUid)->rawSearch($query, $searchParams); + return $this->client->index($indexUid)->rawSearch('' !== $query ? $query : null, $searchParams); } /** @@ -146,7 +134,7 @@ public function count(string $query, string $indexName, array $searchParams): in private function normalizeId($id) { - if (is_object($id) && method_exists($id, '__toString')) { + if (\is_object($id) && method_exists($id, '__toString')) { return (string) $id; } diff --git a/src/Entity/Aggregator.php b/src/Entity/Aggregator.php index 0d89d70a..6bad4094 100644 --- a/src/Entity/Aggregator.php +++ b/src/Entity/Aggregator.php @@ -6,9 +6,6 @@ use Meilisearch\Bundle\Model\Aggregator as BaseAggregator; -/** - * Class Aggregator. - */ abstract class Aggregator extends BaseAggregator { } diff --git a/src/Event/SettingsUpdatedEvent.php b/src/Event/SettingsUpdatedEvent.php new file mode 100644 index 00000000..61070374 --- /dev/null +++ b/src/Event/SettingsUpdatedEvent.php @@ -0,0 +1,61 @@ +index = $index; + $this->class = $class; + $this->setting = $setting; + } + + /** + * @return class-string + */ + public function getClass(): string + { + return $this->class; + } + + /** + * @return non-empty-string + */ + public function getIndex(): string + { + return $this->index; + } + + /** + * @return non-empty-string + */ + public function getSetting(): string + { + return $this->setting; + } +} diff --git a/src/EventListener/ConsoleOutputSubscriber.php b/src/EventListener/ConsoleOutputSubscriber.php new file mode 100644 index 00000000..c497e90d --- /dev/null +++ b/src/EventListener/ConsoleOutputSubscriber.php @@ -0,0 +1,31 @@ +io = $io; + } + + public function afterSettingsUpdate(SettingsUpdatedEvent $event): void + { + $this->io->writeln('Setting "'.$event->getSetting().'" updated of "'.$event->getIndex().'".'); + } + + public static function getSubscribedEvents(): array + { + return [ + SettingsUpdatedEvent::class => 'afterSettingsUpdate', + ]; + } +} diff --git a/src/EventListener/DoctrineEventSubscriber.php b/src/EventListener/DoctrineEventSubscriber.php index 7ece68c0..9f523fac 100644 --- a/src/EventListener/DoctrineEventSubscriber.php +++ b/src/EventListener/DoctrineEventSubscriber.php @@ -4,24 +4,16 @@ namespace Meilisearch\Bundle\EventListener; -use Doctrine\Common\EventSubscriber; use Doctrine\Persistence\Event\LifecycleEventArgs; use Meilisearch\Bundle\SearchService; -final class DoctrineEventSubscriber implements EventSubscriber +final class DoctrineEventSubscriber { private SearchService $searchService; - private array $subscribedEvents; - public function __construct(SearchService $searchService, array $subscribedEvents) + public function __construct(SearchService $searchService) { $this->searchService = $searchService; - $this->subscribedEvents = $subscribedEvents; - } - - public function getSubscribedEvents(): array - { - return $this->subscribedEvents; } public function postUpdate(LifecycleEventArgs $args): void diff --git a/src/Exception/EntityNotFoundInObjectID.php b/src/Exception/EntityNotFoundInObjectID.php index f8081660..d9e1b16a 100644 --- a/src/Exception/EntityNotFoundInObjectID.php +++ b/src/Exception/EntityNotFoundInObjectID.php @@ -4,9 +4,6 @@ namespace Meilisearch\Bundle\Exception; -/** - * Class EntityNotFoundInObjectID. - */ final class EntityNotFoundInObjectID extends \LogicException { } diff --git a/src/Exception/InvalidIndiceException.php b/src/Exception/InvalidIndiceException.php new file mode 100644 index 00000000..83d0f75b --- /dev/null +++ b/src/Exception/InvalidIndiceException.php @@ -0,0 +1,13 @@ +entity = $entity; - if (count($entityIdentifierValues) > 1) { + if (\count($entityIdentifierValues) > 1) { throw new InvalidEntityForAggregator("Aggregators don't support more than one primary key."); } @@ -71,16 +66,13 @@ public static function getEntityClassFromObjectID(string $objectId): string { $type = explode('::', $objectId)[0]; - if (in_array($type, static::getEntities(), true)) { + if (\in_array($type, static::getEntities(), true)) { return $type; } throw new EntityNotFoundInObjectID("Entity class from ObjectID $objectId not found."); } - /** - * {@inheritdoc} - */ public function normalize(NormalizerInterface $normalizer, ?string $format = null, array $context = []): array { return array_merge(['objectID' => $this->objectID], $normalizer->normalize($this->entity, $format, $context)); diff --git a/src/SearchService.php b/src/SearchService.php index c8bb2b02..9fbdcfef 100644 --- a/src/SearchService.php +++ b/src/SearchService.php @@ -6,25 +6,27 @@ use Doctrine\Persistence\ObjectManager; -/** - * Interface SearchService. - */ interface SearchService { public const RESULT_KEY_HITS = 'hits'; public const RESULT_KEY_OBJECTID = 'objectID'; /** - * @param string|object $className + * @param class-string|object $className */ public function isSearchable($className): bool; + /** + * @return list + */ public function getSearchable(): array; public function getConfiguration(): Collection; /** * Get the index name for the given `$className`. + * + * @param class-string $className */ public function searchableAs(string $className): string; @@ -32,12 +34,25 @@ public function index(ObjectManager $objectManager, $searchable): array; public function remove(ObjectManager $objectManager, $searchable): array; + /** + * @param class-string $className + */ public function clear(string $className): array; + /** + * @param class-string $className + */ public function delete(string $className): ?array; public function deleteByIndexName(string $indexName): ?array; + /** + * @template T of object + * + * @param class-string $className + * + * @return list + */ public function search( ObjectManager $objectManager, string $className, @@ -49,6 +64,8 @@ public function search( * Get the raw search result. * * @see https://docs.meilisearch.com/reference/api/search.html#response + * + * @param class-string $className */ public function rawSearch( string $className, @@ -56,5 +73,10 @@ public function rawSearch( array $searchParams = [] ): array; + /** + * @param class-string $className + * + * @return int<0, max> + */ public function count(string $className, string $query = '', array $searchParams = []): int; } diff --git a/src/Searchable.php b/src/Searchable.php index 47e4dd35..b4e3da0a 100644 --- a/src/Searchable.php +++ b/src/Searchable.php @@ -4,9 +4,6 @@ namespace Meilisearch\Bundle; -/** - * Class Searchable. - */ final class Searchable { public const NORMALIZATION_FORMAT = 'searchableArray'; diff --git a/src/SearchableEntity.php b/src/SearchableEntity.php index abe88181..705be5c8 100644 --- a/src/SearchableEntity.php +++ b/src/SearchableEntity.php @@ -6,13 +6,12 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Symfony\Component\Config\Definition\Exception\Exception; +use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Serializer\Exception\ExceptionInterface; +use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizableInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -/** - * Class SearchableEntity. - */ final class SearchableEntity { private string $indexUid; @@ -25,14 +24,15 @@ final class SearchableEntity private ?NormalizerInterface $normalizer; - private bool $useSerializerGroups; + /** + * @var list + */ + private array $normalizationGroups; /** @var int|string */ private $id; /** - * SearchableEntity constructor. - * * @param object $entity * @param ClassMetadata $entityMetadata */ @@ -47,7 +47,7 @@ public function __construct( $this->entity = $entity; $this->entityMetadata = $entityMetadata; $this->normalizer = $normalizer; - $this->useSerializerGroups = isset($extra['useSerializerGroup']) && $extra['useSerializerGroup']; + $this->normalizationGroups = $extra['normalizationGroups'] ?? []; $this->setId(); } @@ -63,11 +63,17 @@ public function getIndexUid(): string public function getSearchableArray(): array { $context = [ + 'meilisearch' => true, 'fieldsMapping' => $this->entityMetadata->fieldMappings, ]; - if ($this->useSerializerGroups) { - $context['groups'] = [Searchable::NORMALIZATION_GROUP]; + if (\count($this->normalizationGroups) > 0) { + $context['groups'] = $this->normalizationGroups; + } + + if (Kernel::VERSION_ID >= 70100) { + $context[DateTimeNormalizer::FORMAT_KEY] = 'U'; + $context[DateTimeNormalizer::CAST_KEY] = 'int'; } if ($this->entity instanceof NormalizableInterface && null !== $this->normalizer) { diff --git a/src/Services/MeilisearchService.php b/src/Services/MeilisearchService.php index d7f2f6e5..9f0130d9 100644 --- a/src/Services/MeilisearchService.php +++ b/src/Services/MeilisearchService.php @@ -5,6 +5,8 @@ namespace Meilisearch\Bundle\Services; use Doctrine\Common\Util\ClassUtils; +use Doctrine\ORM\Mapping\LegacyReflectionFields; +use Doctrine\ORM\Proxy\DefaultProxyClassNameResolver; use Doctrine\Persistence\ObjectManager; use Meilisearch\Bundle\Collection; use Meilisearch\Bundle\Engine; @@ -15,47 +17,51 @@ use Meilisearch\Bundle\SearchService; use Symfony\Component\Config\Definition\Exception\Exception; use Symfony\Component\PropertyAccess\PropertyAccess; -use Symfony\Component\PropertyAccess\PropertyAccessor; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -/** - * Class MeilisearchService. - */ final class MeilisearchService implements SearchService { private NormalizerInterface $normalizer; private Engine $engine; private Collection $configuration; - private PropertyAccessor $propertyAccessor; + private PropertyAccessorInterface $propertyAccessor; + /** + * @var list + */ private array $searchableEntities; + /** + * @var array> + */ private array $entitiesAggregators; + /** + * @var list> + */ private array $aggregators; - private array $classToSerializerGroupMapping; + /** + * @var array> + */ + private array $classToSerializerGroup; private array $indexIfMapping; - public function __construct(NormalizerInterface $normalizer, Engine $engine, array $configuration) + public function __construct(NormalizerInterface $normalizer, Engine $engine, array $configuration, ?PropertyAccessorInterface $propertyAccessor = null) { $this->normalizer = $normalizer; $this->engine = $engine; $this->configuration = new Collection($configuration); - $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); + $this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor(); $this->setSearchableEntities(); $this->setAggregatorsAndEntitiesAggregators(); - $this->setClassToSerializerGroupMapping(); + $this->setClassToSerializerGroup(); $this->setIndexIfMapping(); } - /** - * {@inheritdoc} - */ public function isSearchable($className): bool { - if (is_object($className)) { - $className = ClassUtils::getClass($className); - } + $className = $this->getBaseClassName($className); - return in_array($className, $this->searchableEntities, true); + return \in_array($className, $this->searchableEntities, true); } public function getSearchable(): array @@ -68,11 +74,10 @@ public function getConfiguration(): Collection return $this->configuration; } - /** - * {@inheritdoc} - */ public function searchableAs(string $className): string { + $className = $this->getBaseClassName($className); + $indexes = new Collection($this->getConfiguration()->get('indices')); $index = $indexes->firstWhere('class', $className); @@ -81,36 +86,36 @@ public function searchableAs(string $className): string public function index(ObjectManager $objectManager, $searchable): array { - $searchable = is_array($searchable) ? $searchable : [$searchable]; + $searchable = \is_array($searchable) ? $searchable : [$searchable]; $searchable = array_merge($searchable, $this->getAggregatorsFromEntities($objectManager, $searchable)); - $searchableToBeIndexed = array_filter( + $dataToIndex = array_filter( $searchable, fn ($entity) => $this->isSearchable($entity) ); - $searchableToBeRemoved = []; - foreach ($searchableToBeIndexed as $key => $entity) { + $dataToRemove = []; + foreach ($dataToIndex as $key => $entity) { if (!$this->shouldBeIndexed($entity)) { - unset($searchableToBeIndexed[$key]); - $searchableToBeRemoved[] = $entity; + unset($dataToIndex[$key]); + $dataToRemove[] = $entity; } } - if (count($searchableToBeRemoved) > 0) { - $this->remove($objectManager, $searchableToBeRemoved); + if (\count($dataToRemove) > 0) { + $this->remove($objectManager, $dataToRemove); } return $this->makeSearchServiceResponseFrom( $objectManager, - $searchableToBeIndexed, + $dataToIndex, fn ($chunk) => $this->engine->index($chunk) ); } public function remove(ObjectManager $objectManager, $searchable): array { - $searchable = is_array($searchable) ? $searchable : [$searchable]; + $searchable = \is_array($searchable) ? $searchable : [$searchable]; $searchable = array_merge($searchable, $this->getAggregatorsFromEntities($objectManager, $searchable)); $searchable = array_filter( @@ -152,30 +157,30 @@ public function search( ): array { $this->assertIsSearchable($className); - $ids = $this->engine->search($query, $this->searchableAs($className), $searchParams); + $ids = $this->engine->search($query, $this->searchableAs($className), $searchParams + ['limit' => $this->configuration['nbResults']]); $results = []; // Check if the engine returns results in "hits" key if (!isset($ids[self::RESULT_KEY_HITS])) { - throw new SearchHitsNotFoundException(sprintf('There is no "%s" key in the search results.', self::RESULT_KEY_HITS)); + throw new SearchHitsNotFoundException(\sprintf('There is no "%s" key in the search results.', self::RESULT_KEY_HITS)); } foreach ($ids[self::RESULT_KEY_HITS] as $hit) { if (!isset($hit[self::RESULT_KEY_OBJECTID])) { - throw new ObjectIdNotFoundException(sprintf('There is no "%s" key in the result.', self::RESULT_KEY_OBJECTID)); + throw new ObjectIdNotFoundException(\sprintf('There is no "%s" key in the result.', self::RESULT_KEY_OBJECTID)); } - if (in_array($className, $this->aggregators, true)) { + $documentId = $hit[self::RESULT_KEY_OBJECTID]; + $entityClass = $className; + + if (\in_array($className, $this->aggregators, true)) { $objectId = $hit[self::RESULT_KEY_OBJECTID]; $entityClass = $className::getEntityClassFromObjectId($objectId); - $id = $className::getEntityIdFromObjectId($objectId); - } else { - $id = $hit[self::RESULT_KEY_OBJECTID]; - $entityClass = $className; + $documentId = $className::getEntityIdFromObjectId($objectId); } $repo = $objectManager->getRepository($entityClass); - $entity = $repo->find($id); + $entity = $repo->find($documentId); if (null !== $entity) { $results[] = $entity; @@ -185,9 +190,6 @@ public function search( return $results; } - /** - * {@inheritdoc} - */ public function rawSearch( string $className, string $query = '', @@ -207,7 +209,8 @@ public function count(string $className, string $query = '', array $searchParams public function shouldBeIndexed(object $entity): bool { - $className = ClassUtils::getClass($entity); + $className = $this->getBaseClassName($entity); + $propertyPath = $this->indexIfMapping[$className]; if (null !== $propertyPath) { @@ -221,6 +224,26 @@ public function shouldBeIndexed(object $entity): bool return true; } + /** + * @param object|class-string $objectOrClass + * + * @return class-string + */ + private function getBaseClassName($objectOrClass): string + { + foreach ($this->searchableEntities as $class) { + if (is_a($objectOrClass, $class, true)) { + return $class; + } + } + + if (\is_object($objectOrClass)) { + return self::resolveClass($objectOrClass); + } + + return $objectOrClass; + } + private function setSearchableEntities(): void { $searchable = []; @@ -251,15 +274,15 @@ private function setAggregatorsAndEntitiesAggregators(): void $this->aggregators = array_unique($this->aggregators); } - private function setClassToSerializerGroupMapping(): void + private function setClassToSerializerGroup(): void { $mapping = []; /** @var array $indexDetails */ foreach ($this->configuration->get('indices') as $indexDetails) { - $mapping[$indexDetails['class']] = $indexDetails['enable_serializer_groups']; + $mapping[$indexDetails['class']] = $indexDetails['enable_serializer_groups'] ? $indexDetails['serializer_groups'] : []; } - $this->classToSerializerGroupMapping = $mapping; + $this->classToSerializerGroup = $mapping; } private function setIndexIfMapping(): void @@ -285,8 +308,8 @@ private function getAggregatorsFromEntities(ObjectManager $objectManager, array $aggregators = []; foreach ($entities as $entity) { - $entityClassName = ClassUtils::getClass($entity); - if (array_key_exists($entityClassName, $this->entitiesAggregators)) { + $entityClassName = self::resolveClass($entity); + if (\array_key_exists($entityClassName, $this->entitiesAggregators)) { foreach ($this->entitiesAggregators[$entityClassName] as $aggregator) { $aggregators[] = new $aggregator( $entity, @@ -313,14 +336,14 @@ private function makeSearchServiceResponseFrom( foreach (array_chunk($entities, $this->configuration->get('batchSize')) as $chunk) { $searchableEntitiesChunk = []; foreach ($chunk as $entity) { - $entityClassName = ClassUtils::getClass($entity); + $entityClassName = $this->getBaseClassName($entity); $searchableEntitiesChunk[] = new SearchableEntity( $this->searchableAs($entityClassName), $entity, $objectManager->getClassMetadata($entityClassName), $this->normalizer, - ['useSerializerGroup' => $this->canUseSerializerGroup($entityClassName)] + ['normalizationGroups' => $this->getNormalizationGroups($entityClassName)] ); } @@ -330,9 +353,14 @@ private function makeSearchServiceResponseFrom( return $batch; } - private function canUseSerializerGroup(string $className): bool + /** + * @param class-string $className + * + * @return list + */ + private function getNormalizationGroups(string $className): array { - return $this->classToSerializerGroupMapping[$className]; + return $this->classToSerializerGroup[$className]; } private function assertIsSearchable(string $className): void @@ -341,4 +369,26 @@ private function assertIsSearchable(string $className): void throw new Exception('Class '.$className.' is not searchable.'); } } + + private static function resolveClass(object $object): string + { + static $resolver; + + $resolver ??= (function () { + // 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/SettingsUpdater.php b/src/Services/SettingsUpdater.php new file mode 100644 index 00000000..3ef47aa9 --- /dev/null +++ b/src/Services/SettingsUpdater.php @@ -0,0 +1,79 @@ +searchClient = $searchClient; + $this->eventDispatcher = $eventDispatcher; + $this->configuration = $searchService->getConfiguration(); + } + + /** + * @param non-empty-string $indice + * @param positive-int|null $responseTimeout + */ + public function update(string $indice, ?int $responseTimeout = null, ?string $prefixedName = null): void + { + $index = (new Collection($this->configuration->get('indices')))->firstWhere('prefixed_name', $indice); + + if (!\is_array($index)) { + throw new InvalidIndiceException($indice); + } + + if (!\is_array($index['settings'] ?? null) || [] === $index['settings']) { + return; + } + + $indexName = $prefixedName ?? $index['prefixed_name']; + $indexInstance = $this->searchClient->index($indexName); + $responseTimeout = $responseTimeout ?? self::DEFAULT_RESPONSE_TIMEOUT; + + foreach ($index['settings'] as $variable => $value) { + $method = \sprintf('update%s', ucfirst($variable)); + + if (!method_exists($indexInstance, $method)) { + throw new InvalidSettingName(\sprintf('Invalid setting name: "%s"', $variable)); + } + + if (isset($value['_service']) && $value['_service'] instanceof SettingsProvider) { + $value = $value['_service'](); + } elseif (('distinctAttribute' === $variable || 'proximityPrecision' === $variable || 'searchCutoffMs' === $variable) && \is_array($value)) { + $value = $value[0] ?? null; + } + + // Update + $task = $indexInstance->{$method}($value); + + // Get task information using uid + $indexInstance->waitForTask($task['taskUid'], $responseTimeout); + $task = $indexInstance->getTask($task['taskUid']); + + if ('failed' === $task['status']) { + throw new TaskException($task['error']['message']); + } + + $this->eventDispatcher->dispatch(new SettingsUpdatedEvent($index['class'], $indexName, $variable)); + } + } +} diff --git a/src/Services/UnixTimestampNormalizer.php b/src/Services/UnixTimestampNormalizer.php new file mode 100644 index 00000000..93b527ae --- /dev/null +++ b/src/Services/UnixTimestampNormalizer.php @@ -0,0 +1,33 @@ +getTimestamp(); + } + + /** + * @param mixed $data + */ + public function supportsNormalization($data, ?string $format = null, array $context = []): bool + { + return $data instanceof \DateTimeInterface && true === ($context['meilisearch'] ?? null); + } + + public function getSupportedTypes(?string $format): array + { + return [ + \DateTimeInterface::class => true, // @codeCoverageIgnore + ]; + } +} diff --git a/src/SettingsProvider.php b/src/SettingsProvider.php new file mode 100644 index 00000000..42e2f140 --- /dev/null +++ b/src/SettingsProvider.php @@ -0,0 +1,13 @@ + + */ + public function __invoke(): array; +} diff --git a/tests/BaseKernelTestCase.php b/tests/BaseKernelTestCase.php index a1021b37..76f07f57 100644 --- a/tests/BaseKernelTestCase.php +++ b/tests/BaseKernelTestCase.php @@ -7,21 +7,15 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Tools\SchemaTool; use Meilisearch\Bundle\Collection; -use Meilisearch\Bundle\SearchableEntity; use Meilisearch\Bundle\SearchService; -use Meilisearch\Bundle\Tests\Entity\Comment; -use Meilisearch\Bundle\Tests\Entity\Image; -use Meilisearch\Bundle\Tests\Entity\Link; -use Meilisearch\Bundle\Tests\Entity\ObjectId\DummyObjectId; -use Meilisearch\Bundle\Tests\Entity\Page; -use Meilisearch\Bundle\Tests\Entity\Post; -use Meilisearch\Bundle\Tests\Entity\Tag; +use Meilisearch\Client; use Meilisearch\Exceptions\ApiException; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; abstract class BaseKernelTestCase extends KernelTestCase { protected EntityManagerInterface $entityManager; + protected Client $client; protected SearchService $searchService; protected function setUp(): void @@ -29,7 +23,8 @@ protected function setUp(): void self::bootKernel(); $this->entityManager = $this->get('doctrine.orm.entity_manager'); - $this->searchService = $this->get('search.service'); + $this->client = $this->get('meilisearch.client'); + $this->searchService = $this->get('meilisearch.service'); $metaData = $this->entityManager->getMetadataFactory()->getAllMetadata(); $tool = new SchemaTool($this->entityManager); @@ -39,137 +34,6 @@ protected function setUp(): void $this->cleanUp(); } - /** - * @param int|string|null $id - */ - protected function createPost($id = null): Post - { - $post = new Post(); - $post->setTitle('Test Post'); - $post->setContent('Test content post'); - - if (null !== $id) { - $post->setId($id); - } - - $this->entityManager->persist($post); - $this->entityManager->flush(); - - return $post; - } - - protected function createPage(int $id): Page - { - $page = new Page(); - $page->setTitle('Test Page'); - $page->setContent('Test content page'); - $page->setId(new DummyObjectId($id)); - - $this->entityManager->persist($page); - $this->entityManager->flush(); - - return $page; - } - - protected function createSearchablePost(): SearchableEntity - { - $post = $this->createPost(random_int(100, 300)); - - return new SearchableEntity( - $this->getPrefix().'posts', - $post, - $this->get('doctrine')->getManager()->getClassMetadata(Post::class), - $this->get('serializer') - ); - } - - /** - * @param int|string|null $id - */ - protected function createComment($id = null): Comment - { - $post = new Post(['title' => 'What a post!']); - $comment = new Comment(); - $comment->setContent('Comment content'); - $comment->setPost($post); - - if (null !== $id) { - $comment->setId($id); - } - - $this->entityManager->persist($post); - $this->entityManager->persist($comment); - $this->entityManager->flush(); - - return $comment; - } - - /** - * @param int|string|null $id - */ - protected function createImage($id = null): Image - { - $image = new Image(); - $image->setUrl('https://docs.meilisearch.com/logo.png'); - - if (null !== $id) { - $image->setId($id); - } - - $this->entityManager->persist($image); - $this->entityManager->flush(); - - return $image; - } - - protected function createSearchableImage(): SearchableEntity - { - $image = $this->createImage(random_int(100, 300)); - - return new SearchableEntity( - $this->getPrefix().'image', - $image, - $this->get('doctrine')->getManager()->getClassMetadata(Image::class), - null - ); - } - - protected function createTag(array $properties = []): Tag - { - $tag = new Tag(); - $tag->setName('Meilisearch Test Tag'); - - if (count($properties) > 0) { - foreach ($properties as $key => $value) { - $method = 'set'.ucfirst($key); - $tag->$method($value); - } - } - - $this->entityManager->persist($tag); - $this->entityManager->flush(); - - return $tag; - } - - protected function createLink(array $properties = []): Link - { - $link = new Link(); - $link->setName('Meilisearch Test Link'); - - if (count($properties) > 0) { - foreach ($properties as $key => $value) { - $method = 'set'.ucfirst($key); - $link->$method($value); - } - } - - $this->entityManager->persist($link); - $this->entityManager->flush(); - - return $link; - } - protected function getPrefix(): string { return $this->searchService->getConfiguration()->get('prefix'); @@ -180,22 +44,20 @@ protected function get(string $id): ?object return self::getContainer()->get($id); } - protected function getFileName(string $indexName, string $type): string + protected function waitForAllTasks(): void { - return sprintf('%s/%s.json', $indexName, $type); + $firstTask = $this->client->getTasks()->getResults()[0]; + $this->client->waitForTask($firstTask['uid']); } private function cleanUp(): void { (new Collection($this->searchService->getConfiguration()->get('indices'))) ->each(function ($item): bool { - $this->cleanupIndex($this->getPrefix().$item['name']); + $this->cleanupIndex($item['prefixed_name']); return true; }); - - $this->cleanupIndex($this->getPrefix().'indexA'); - $this->cleanupIndex($this->getPrefix().'indexB'); } private function cleanupIndex(string $indexName): void diff --git a/tests/Dbal/Type/DummyObjectIdType.php b/tests/Dbal/Type/DummyObjectIdType.php new file mode 100644 index 00000000..0c07206f --- /dev/null +++ b/tests/Dbal/Type/DummyObjectIdType.php @@ -0,0 +1,65 @@ +getIntegerTypeDeclarationSQL($column); + } + + public function convertToPHPValue($value, AbstractPlatform $platform): ?DummyObjectId + { + if ($value instanceof DummyObjectId || null === $value) { + return $value; + } + + if (!\is_string($value) && !\is_int($value)) { + $actualType = get_debug_type($value); + $possibleTypes = ['null', 'string', 'int', self::class]; + throw new ConversionException(\sprintf("Could not convert PHP value '%s' of type '%s' to type '%s'. Expected one of the following types: %s", $value, $actualType, $this->getName(), implode(', ', $possibleTypes))); + } + + return new DummyObjectId((int) $value); + } + + /** + * @throws ConversionException + */ + public function convertToDatabaseValue($value, AbstractPlatform $platform): ?int + { + if ($value instanceof DummyObjectId) { + return $value->toInt(); + } + + if (null === $value || '' === $value) { + return null; + } + + if (!\is_string($value) && !\is_int($value)) { + $actualType = get_debug_type($value); + $possibleTypes = ['null', 'string', 'int', self::class]; + throw new ConversionException(\sprintf("Could not convert PHP value '%s' of type '%s' to type '%s'. Expected one of the following types: %s", $value, $actualType, $this->getName(), implode(', ', $possibleTypes))); + } + + return (int) $value; + } + + public function requiresSQLCommentHint(AbstractPlatform $platform): bool + { + return true; + } +} diff --git a/tests/Entity/Article.php b/tests/Entity/Article.php new file mode 100644 index 00000000..a1e588ba --- /dev/null +++ b/tests/Entity/Article.php @@ -0,0 +1,15 @@ + $attributes - */ - public function __construct(array $attributes = []) + public function __construct(Post $post, string $content, ?\DateTimeImmutable $publishedAt = null) { - $this->id = $attributes['id'] ?? null; - $this->content = $attributes['content'] ?? null; - $this->publishedAt = $attributes['publishedAt'] ?? new \DateTime(); - $this->post = $attributes['post'] ?? null; + $this->post = $post; + $this->content = $content; + $this->publishedAt = $publishedAt ?? new \DateTimeImmutable(); } public function getId(): ?int @@ -73,46 +71,25 @@ public function getId(): ?int return $this->id; } - public function setId(?int $id): Comment - { - $this->id = $id; - - return $this; - } - - public function getContent(): ?string + public function getContent(): string { return $this->content; } - public function setContent(?string $content): Comment + public function setContent(string $content): Comment { $this->content = $content; return $this; } - public function getPublishedAt(): \DateTime + public function getPublishedAt(): \DateTimeImmutable { return $this->publishedAt; } - public function setPublishedAt(\DateTime $publishedAt): Comment - { - $this->publishedAt = $publishedAt; - - return $this; - } - public function getPost(): ?Post { return $this->post; } - - public function setPost(Post $post): Comment - { - $this->post = $post; - - return $this; - } } diff --git a/tests/Entity/ContentAggregator.php b/tests/Entity/ContentAggregator.php index 379a7dbe..2346f168 100644 --- a/tests/Entity/ContentAggregator.php +++ b/tests/Entity/ContentAggregator.php @@ -10,6 +10,7 @@ /** * @ORM\Entity */ +#[ORM\Entity] class ContentAggregator extends Aggregator { public function getIsVisible(): bool diff --git a/tests/Entity/ContentItem.php b/tests/Entity/ContentItem.php new file mode 100644 index 00000000..8f1aa33e --- /dev/null +++ b/tests/Entity/ContentItem.php @@ -0,0 +1,57 @@ + Article::class, 2 => Podcast::class])] +abstract class ContentItem +{ + /** + * @ORM\Id + * + * @ORM\GeneratedValue + * + * @ORM\Column(type="integer") + */ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: Types::INTEGER)] + private ?int $id = null; + + /** + * @ORM\Column(type="string") + */ + #[ORM\Column(type: Types::STRING)] + private string $title; + + public function __construct(string $title = 'Title') + { + $this->title = $title; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } +} diff --git a/tests/Entity/DummyCustomGroups.php b/tests/Entity/DummyCustomGroups.php new file mode 100644 index 00000000..d7923584 --- /dev/null +++ b/tests/Entity/DummyCustomGroups.php @@ -0,0 +1,70 @@ +id = $id; + $this->name = $name; + $this->createdAt = $createdAt; + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/tests/Entity/DynamicSettings.php b/tests/Entity/DynamicSettings.php new file mode 100644 index 00000000..b475319e --- /dev/null +++ b/tests/Entity/DynamicSettings.php @@ -0,0 +1,48 @@ +id = $id; + $this->name = $name; + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/tests/Entity/EmptyAggregator.php b/tests/Entity/EmptyAggregator.php index 5d9f8202..ae622370 100644 --- a/tests/Entity/EmptyAggregator.php +++ b/tests/Entity/EmptyAggregator.php @@ -10,6 +10,7 @@ /** * @ORM\Entity */ +#[ORM\Entity] class EmptyAggregator extends Aggregator { } diff --git a/tests/Entity/Image.php b/tests/Entity/Image.php index 237d1bfa..7f9fc78d 100644 --- a/tests/Entity/Image.php +++ b/tests/Entity/Image.php @@ -4,11 +4,13 @@ namespace Meilisearch\Bundle\Tests\Entity; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity */ +#[ORM\Entity] class Image { /** @@ -18,16 +20,20 @@ class Image * * @ORM\Column(type="integer") */ - private ?int $id; + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: Types::INTEGER)] + private ?int $id = null; /** * @ORM\Column(type="string") */ + #[ORM\Column(type: Types::STRING)] private string $url; - public function __construct() + public function __construct(string $url = 'https://docs.meilisearch.com/logo.png') { - $this->url = 'https://docs.meilisearch.com/logo.png'; + $this->url = $url; } public function getId(): ?int @@ -35,25 +41,11 @@ public function getId(): ?int return $this->id; } - public function setId(?int $id): Image - { - $this->id = $id; - - return $this; - } - public function getUrl(): string { return $this->url; } - public function setUrl(string $url): Image - { - $this->url = $url; - - return $this; - } - public function isPublic(): bool { return true; diff --git a/tests/Entity/Link.php b/tests/Entity/Link.php index 0c9ee047..bf94b661 100644 --- a/tests/Entity/Link.php +++ b/tests/Entity/Link.php @@ -4,6 +4,7 @@ namespace Meilisearch\Bundle\Tests\Entity; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Meilisearch\Bundle\Searchable; use Symfony\Component\Serializer\Normalizer\NormalizableInterface; @@ -12,6 +13,7 @@ /** * @ORM\Entity */ +#[ORM\Entity] class Link implements NormalizableInterface { /** @@ -19,33 +21,39 @@ class Link implements NormalizableInterface * * @ORM\Column(type="integer") */ + #[ORM\Id] + #[ORM\Column(type: Types::INTEGER)] private int $id; /** * @ORM\Column(type="string") */ - private string $name = 'Test link'; + #[ORM\Column(type: Types::STRING)] + private string $name; /** * @ORM\Column(type="string") */ - private string $url = 'https://docs.meilisearch.com'; + #[ORM\Column(type: Types::STRING)] + private string $url; /** * @ORM\Column(type="boolean", options={"default"=false}) */ - private bool $isSponsored = false; + #[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])] + private bool $isSponsored; - public function getId(): int + public function __construct(int $id, string $name = 'Test link', string $url = 'https://docs.meilisearch.com', bool $isSponsored = false) { - return $this->id; + $this->id = $id; + $this->name = $name; + $this->url = $url; + $this->isSponsored = $isSponsored; } - public function setId(int $id): Link + public function getId(): int { - $this->id = $id; - - return $this; + return $this->id; } public function getName(): string @@ -53,40 +61,16 @@ public function getName(): string return $this->name; } - public function setName(string $name): Link - { - $this->name = $name; - - return $this; - } - public function getUrl(): string { return $this->url; } - public function setUrl(string $url): Link - { - $this->url = $url; - - return $this; - } - public function isSponsored(): bool { return $this->isSponsored; } - public function setIsSponsored(bool $isSponsored): Link - { - $this->isSponsored = $isSponsored; - - return $this; - } - - /** - * {@inheritDoc} - */ public function normalize(NormalizerInterface $normalizer, $format = null, array $context = []): array { if (Searchable::NORMALIZATION_FORMAT === $format) { diff --git a/tests/Entity/ObjectId/DummyObjectId.php b/tests/Entity/ObjectId/DummyObjectId.php index 7dc511d9..6c1e41e1 100644 --- a/tests/Entity/ObjectId/DummyObjectId.php +++ b/tests/Entity/ObjectId/DummyObjectId.php @@ -4,7 +4,7 @@ namespace Meilisearch\Bundle\Tests\Entity\ObjectId; -class DummyObjectId +final class DummyObjectId { private int $id; @@ -13,7 +13,12 @@ public function __construct(int $id) $this->id = $id; } - public function __toString() + public function toInt(): int + { + return $this->id; + } + + public function __toString(): string { return (string) $this->id; } diff --git a/tests/Entity/Page.php b/tests/Entity/Page.php index 3200921c..2c30e2d6 100644 --- a/tests/Entity/Page.php +++ b/tests/Entity/Page.php @@ -4,7 +4,9 @@ namespace Meilisearch\Bundle\Tests\Entity; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use Meilisearch\Bundle\Tests\Entity\ObjectId\DummyObjectId; use Symfony\Component\Serializer\Annotation\Groups; /** @@ -12,6 +14,8 @@ * * @ORM\Table(name="pages") */ +#[ORM\Entity] +#[ORM\Table(name: 'pages')] class Page { /** @@ -19,57 +23,55 @@ class Page * * @ORM\GeneratedValue(strategy="NONE") * - * @ORM\Column(type="object") + * @ORM\Column(type="dummy_object_id") */ - private $id = null; + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'NONE')] + #[ORM\Column(type: 'dummy_object_id')] + private DummyObjectId $id; /** * @ORM\Column(type="string", nullable=true) * * @Groups({"searchable"}) */ - private ?string $title = null; + #[ORM\Column(type: Types::STRING)] + #[Groups('searchable')] + private string $title; /** * @ORM\Column(type="text", nullable=true) * * @Groups({"searchable"}) */ - private ?string $content = null; + #[ORM\Column(type: Types::TEXT)] + #[Groups('searchable')] + private string $content; - public function getId() - { - return $this->id; - } - - public function setId($id): self + public function __construct(DummyObjectId $id, string $title = 'Test page', string $content = 'Test content page') { $this->id = $id; - - return $this; + $this->title = $title; + $this->content = $content; } - public function getTitle(): ?string + public function getId(): DummyObjectId { - return $this->title; + return $this->id; } - public function setTitle(?string $title): self + public function setTitle(string $title): void { $this->title = $title; - - return $this; } - public function getContent(): ?string + public function getTitle(): ?string { - return $this->content; + return $this->title; } - public function setContent(?string $content): self + public function getContent(): ?string { - $this->content = $content; - - return $this; + return $this->content; } } diff --git a/tests/Entity/Podcast.php b/tests/Entity/Podcast.php new file mode 100644 index 00000000..ff800217 --- /dev/null +++ b/tests/Entity/Podcast.php @@ -0,0 +1,15 @@ + * * @ORM\OneToMany( - * targetEntity="Comment", - * mappedBy="post", - * orphanRemoval=true + * targetEntity="Comment", + * mappedBy="post", + * cascade={"persist"}, + * orphanRemoval=true * ) * * @ORM\OrderBy({"publishedAt": "DESC"}) * * @Groups({"searchable"}) */ - private $comments; + #[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'post', cascade: ['persist'], orphanRemoval: true)] + #[ORM\OrderBy(['publishedAt' => 'DESC'])] + #[Groups('searchable')] + private Collection $comments; - /** - * Post constructor. - */ - public function __construct(array $attributes = []) + public function __construct(?string $title = null, ?string $content = null, ?\DateTimeImmutable $publishedAt = null) { - $this->id = $attributes['id'] ?? null; - $this->title = $attributes['title'] ?? null; - $this->content = $attributes['content'] ?? null; - $this->publishedAt = $attributes['publishedAt'] ?? new \DateTime(); - $this->comments = new ArrayCollection($attributes['comments'] ?? []); + $this->title = $title; + $this->content = $content; + $this->publishedAt = $publishedAt ?? new \DateTimeImmutable(); + $this->comments = new ArrayCollection(); } public function getId(): ?int @@ -82,11 +93,9 @@ public function getId(): ?int return $this->id; } - public function setId(?int $id): Post + public function setTitle(string $title): void { - $this->id = $id; - - return $this; + $this->title = $title; } /** @@ -97,60 +106,30 @@ public function getTitle(): ?string return $this->title; } - public function setTitle(?string $title): Post - { - $this->title = $title; - - return $this; - } - public function getContent(): ?string { return $this->content; } - public function setContent(?string $content): Post - { - $this->content = $content; - - return $this; - } - /** * @Groups({"searchable"}) */ - public function getPublishedAt(): ?\DateTime + public function getPublishedAt(): \DateTimeImmutable { return $this->publishedAt; } - public function setPublishedAt(?\DateTime $publishedAt): Post - { - $this->publishedAt = $publishedAt; - - return $this; - } - - public function getComments(): ?Collection + public function getComments(): Collection { return $this->comments; } public function addComment(Comment $comment): Post { - $comment->setPost($this); if (!$this->comments->contains($comment)) { $this->comments->add($comment); } return $this; } - - public function removeComment(Comment $comment): Post - { - $comment->setPost($this); - $this->comments->removeElement($comment); - - return $this; - } } diff --git a/tests/Entity/SelfNormalizable.php b/tests/Entity/SelfNormalizable.php index e52e2e9d..3895424e 100644 --- a/tests/Entity/SelfNormalizable.php +++ b/tests/Entity/SelfNormalizable.php @@ -4,6 +4,7 @@ namespace Meilisearch\Bundle\Tests\Entity; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Meilisearch\Bundle\Searchable; use Symfony\Component\Serializer\Normalizer\NormalizableInterface; @@ -12,23 +13,30 @@ /** * @ORM\Entity */ +#[ORM\Entity] class SelfNormalizable implements NormalizableInterface { /** * @ORM\Id * * @ORM\Column(type="integer") + * + * @ORM\GeneratedValue("NONE") */ + #[ORM\Id] + #[ORM\Column(type: Types::INTEGER)] private int $id; /** * @ORM\Column(type="string") */ + #[ORM\Column(type: Types::STRING)] private string $name; /** * @ORM\Column(type="datetime_immutable") */ + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] private \DateTimeImmutable $createdAt; public function __construct(int $id, string $name) diff --git a/tests/Entity/Tag.php b/tests/Entity/Tag.php index 6c9dd608..63f11fbd 100644 --- a/tests/Entity/Tag.php +++ b/tests/Entity/Tag.php @@ -4,6 +4,7 @@ namespace Meilisearch\Bundle\Tests\Entity; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Meilisearch\Bundle\Searchable; use Symfony\Component\Serializer\Exception\ExceptionInterface; @@ -13,35 +14,43 @@ /** * @ORM\Entity */ +#[ORM\Entity] class Tag implements NormalizableInterface { /** * @ORM\Id * - * @ORM\Column(type="integer", nullable=true) + * @ORM\Column(type="integer") */ - private ?int $id; + #[ORM\Id] + #[ORM\Column(type: Types::INTEGER)] + private int $id; /** * @ORM\Column(type="string") */ - private string $name = ''; + #[ORM\Column(type: Types::STRING)] + private string $name; /** * @ORM\Column(type="smallint") */ + #[ORM\Column(type: Types::SMALLINT)] private int $count = 0; private bool $public = true; /** - * @ORM\Column(type="datetime") + * @ORM\Column(type="datetime_immutable") */ - private \DateTimeInterface $publishedAt; + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private \DateTimeImmutable $publishedAt; - public function __construct() + public function __construct(int $id, string $name = '') { - $this->publishedAt = new \DateTime(); + $this->id = $id; + $this->name = $name; + $this->publishedAt = new \DateTimeImmutable(); } public function getId(): ?int @@ -49,13 +58,6 @@ public function getId(): ?int return $this->id; } - public function setId(?int $id): Tag - { - $this->id = $id; - - return $this; - } - public function getName(): string { return $this->name; @@ -81,8 +83,6 @@ public function setPublic(bool $public): Tag } /** - * {@inheritDoc} - * * @throws ExceptionInterface */ public function normalize(NormalizerInterface $normalizer, $format = null, array $context = []): array diff --git a/tests/Integration/AggregatorTest.php b/tests/Integration/AggregatorTest.php index ce81ebc9..762d199c 100644 --- a/tests/Integration/AggregatorTest.php +++ b/tests/Integration/AggregatorTest.php @@ -4,6 +4,8 @@ namespace Meilisearch\Bundle\Tests\Integration; +use Doctrine\ORM\Mapping\LegacyReflectionFields; +use Doctrine\Persistence\Proxy; use Meilisearch\Bundle\Exception\EntityNotFoundInObjectID; use Meilisearch\Bundle\Exception\InvalidEntityForAggregator; use Meilisearch\Bundle\Tests\BaseKernelTestCase; @@ -12,7 +14,7 @@ use Meilisearch\Bundle\Tests\Entity\Post; use Symfony\Component\Serializer\Serializer; -class AggregatorTest extends BaseKernelTestCase +final class AggregatorTest extends BaseKernelTestCase { public function testGetEntities(): void { @@ -34,18 +36,24 @@ public function testConstructor(): void public function testAggregatorProxyClass(): void { - $this->createPost(); + if (class_exists(LegacyReflectionFields::class)) { + $this->markTestSkipped('Skipping, because proxies are not wrapped anymore with lazy native objects.'); + } - $postMetadata = $this->entityManager->getClassMetadata(Post::class); - $this->entityManager->getProxyFactory()->generateProxyClasses([$postMetadata]); + $this->entityManager->persist($post = new Post()); + $this->entityManager->flush(); + $postId = $post->getId(); + $this->entityManager->clear(); - $proxy = $this->entityManager->getProxyFactory()->getProxy($postMetadata->getName(), ['id' => 1]); + $proxy = $this->entityManager->getReference(Post::class, $postId); + $this->assertInstanceOf(Proxy::class, $proxy); $contentAggregator = new ContentAggregator($proxy, ['objectId']); /** @var Serializer $serializer */ $serializer = $this->get('serializer'); $serializedData = $contentAggregator->normalize($serializer); + $this->assertNotEmpty($serializedData); $this->assertEquals('objectId', $serializedData['objectID']); } diff --git a/tests/Integration/Command/MeilisearchClearCommandTest.php b/tests/Integration/Command/MeilisearchClearCommandTest.php new file mode 100644 index 00000000..19c4b64c --- /dev/null +++ b/tests/Integration/Command/MeilisearchClearCommandTest.php @@ -0,0 +1,75 @@ +application = new Application(self::createKernel()); + } + + public function testClear(): void + { + $command = $this->application->find('meilisearch:clear'); + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $this->assertSame(<<<'EOD' +Cleared sf_phpunit__posts index of Meilisearch\Bundle\Tests\Entity\Post +Cleared sf_phpunit__comments index of Meilisearch\Bundle\Tests\Entity\Comment +Cleared sf_phpunit__aggregated index of Meilisearch\Bundle\Tests\Entity\ContentAggregator +Cleared sf_phpunit__tags index of Meilisearch\Bundle\Tests\Entity\Tag +Cleared sf_phpunit__tags index of Meilisearch\Bundle\Tests\Entity\Link +Cleared sf_phpunit__discriminator_map index of Meilisearch\Bundle\Tests\Entity\ContentItem +Cleared sf_phpunit__pages index of Meilisearch\Bundle\Tests\Entity\Page +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 +Done! + +EOD, $commandTester->getDisplay()); + } + + public function testClearWithIndice(): void + { + $command = $this->application->find('meilisearch:clear'); + $commandTester = new CommandTester($command); + $commandTester->execute(['--indices' => 'posts']); + + $this->assertSame(<<<'EOD' +Cleared sf_phpunit__posts index of Meilisearch\Bundle\Tests\Entity\Post +Done! + +EOD, $commandTester->getDisplay()); + } + + public function testClearUnknownIndex(): void + { + $command = $this->application->find('meilisearch:clear'); + $commandTester = new CommandTester($command); + + $commandTester->execute([ + '--indices' => 'test', + ]); + + $this->assertStringContainsString('Cannot clear index. Not found.', $commandTester->getDisplay()); + } + + public function testAlias(): void + { + $command = $this->application->find('meilisearch:clear'); + + self::assertSame(['meili:clear'], $command->getAliases()); + } +} diff --git a/tests/Integration/Command/MeilisearchCreateCommandTest.php b/tests/Integration/Command/MeilisearchCreateCommandTest.php new file mode 100644 index 00000000..6a658f45 --- /dev/null +++ b/tests/Integration/Command/MeilisearchCreateCommandTest.php @@ -0,0 +1,152 @@ +application = new Application(self::createKernel()); + } + + public function testExecuteIndexCreation(): void + { + $createCommand = $this->application->find('meilisearch:create'); + $createCommandTester = new CommandTester($createCommand); + $createCommandTester->execute([]); + + $this->assertSame($this->client->getTasks()->getResults()[0]['type'], 'indexCreation'); + } + + /** + * @testWith [false] + * [true] + */ + public function testWithoutIndices(bool $updateSettings): void + { + $createCommand = $this->application->find('meilisearch:create'); + $createCommandTester = new CommandTester($createCommand); + $createCommandTester->execute($updateSettings ? [] : ['--no-update-settings' => true]); + + $createOutput = $createCommandTester->getDisplay(); + + if ($updateSettings) { + $this->assertSame(<<<'EOD' +Creating index sf_phpunit__posts for Meilisearch\Bundle\Tests\Entity\Post +Setting "stopWords" updated of "sf_phpunit__posts". +Setting "filterableAttributes" updated of "sf_phpunit__posts". +Setting "searchCutoffMs" updated of "sf_phpunit__posts". +Setting "typoTolerance" updated of "sf_phpunit__posts". +Creating index sf_phpunit__comments for Meilisearch\Bundle\Tests\Entity\Comment +Creating index sf_phpunit__tags for Meilisearch\Bundle\Tests\Entity\Tag +Creating index sf_phpunit__tags for Meilisearch\Bundle\Tests\Entity\Link +Creating index sf_phpunit__discriminator_map for Meilisearch\Bundle\Tests\Entity\ContentItem +Creating index sf_phpunit__pages for Meilisearch\Bundle\Tests\Entity\Page +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 +Setting "filterableAttributes" updated of "sf_phpunit__dynamic_settings". +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__aggregated for Meilisearch\Bundle\Tests\Entity\Post +Creating index sf_phpunit__aggregated for Meilisearch\Bundle\Tests\Entity\Tag +Done! + +EOD, $createOutput); + } else { + $this->assertSame(<<<'EOD' +Creating index sf_phpunit__posts for Meilisearch\Bundle\Tests\Entity\Post +Creating index sf_phpunit__comments for Meilisearch\Bundle\Tests\Entity\Comment +Creating index sf_phpunit__tags for Meilisearch\Bundle\Tests\Entity\Tag +Creating index sf_phpunit__tags for Meilisearch\Bundle\Tests\Entity\Link +Creating index sf_phpunit__discriminator_map for Meilisearch\Bundle\Tests\Entity\ContentItem +Creating index sf_phpunit__pages for Meilisearch\Bundle\Tests\Entity\Page +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__aggregated for Meilisearch\Bundle\Tests\Entity\Post +Creating index sf_phpunit__aggregated for Meilisearch\Bundle\Tests\Entity\Tag +Done! + +EOD, $createOutput); + } + } + + public function testWithIndices(): void + { + $createCommand = $this->application->find('meilisearch:create'); + $createCommandTester = new CommandTester($createCommand); + $createCommandTester->execute([ + '--indices' => 'posts', + ]); + + $createOutput = $createCommandTester->getDisplay(); + + $this->assertSame(<<<'EOD' +Creating index sf_phpunit__posts for Meilisearch\Bundle\Tests\Entity\Post +Setting "stopWords" updated of "sf_phpunit__posts". +Setting "filterableAttributes" updated of "sf_phpunit__posts". +Setting "searchCutoffMs" updated of "sf_phpunit__posts". +Setting "typoTolerance" updated of "sf_phpunit__posts". +Done! + +EOD, $createOutput); + } + + public function testWithDynamicSettings(): void + { + for ($i = 0; $i <= 5; ++$i) { + $this->entityManager->persist(new DynamicSettings($i, "Dynamic $i")); + } + + $this->entityManager->flush(); + + $importCommand = $this->application->find('meilisearch:create'); + $importCommandTester = new CommandTester($importCommand); + $importCommandTester->execute(['--indices' => 'dynamic_settings']); + + $importOutput = $importCommandTester->getDisplay(); + + $this->assertSame(<<<'EOD' +Creating index sf_phpunit__dynamic_settings for Meilisearch\Bundle\Tests\Entity\DynamicSettings +Setting "filterableAttributes" updated of "sf_phpunit__dynamic_settings". +Setting "searchableAttributes" updated of "sf_phpunit__dynamic_settings". +Setting "stopWords" updated of "sf_phpunit__dynamic_settings". +Setting "synonyms" updated of "sf_phpunit__dynamic_settings". +Done! + +EOD, $importOutput); + + $settings = $this->get('meilisearch.client')->index('sf_phpunit__dynamic_settings')->getSettings(); + + $getSetting = static fn ($value) => $value instanceof \IteratorAggregate ? iterator_to_array($value) : $value; + + $filterableAttributes = $getSetting($settings['filterableAttributes']); + sort($filterableAttributes); + $expected = ['publishedAt', 'title']; + sort($expected); + self::assertSame($expected, $filterableAttributes); + self::assertSame(['title'], $getSetting($settings['searchableAttributes'])); + self::assertSame(['a', 'n', 'the'], $getSetting($settings['stopWords'])); + self::assertSame(['fantastic' => ['great'], 'great' => ['fantastic']], $getSetting($settings['synonyms'])); + } + + public function testAlias(): void + { + $command = $this->application->find('meilisearch:create'); + + self::assertSame(['meili:create'], $command->getAliases()); + } +} diff --git a/tests/Integration/Command/MeilisearchDeleteCommandTest.php b/tests/Integration/Command/MeilisearchDeleteCommandTest.php new file mode 100644 index 00000000..94c00276 --- /dev/null +++ b/tests/Integration/Command/MeilisearchDeleteCommandTest.php @@ -0,0 +1,66 @@ +application = new Application(self::createKernel()); + } + + public function testDeleteWithoutIndices(): void + { + $clearCommand = $this->application->find('meilisearch:delete'); + $clearCommandTester = new CommandTester($clearCommand); + $clearCommandTester->execute([]); + + $clearOutput = $clearCommandTester->getDisplay(); + + $this->assertSame(<<<'EOD' +Deleted sf_phpunit__posts +Deleted sf_phpunit__comments +Deleted sf_phpunit__aggregated +Deleted sf_phpunit__tags +Deleted sf_phpunit__discriminator_map +Deleted sf_phpunit__pages +Deleted sf_phpunit__self_normalizable +Deleted sf_phpunit__dummy_custom_groups +Deleted sf_phpunit__dynamic_settings +Done! + +EOD, $clearOutput); + } + + public function testDeleteWithIndices(): void + { + $clearCommand = $this->application->find('meilisearch:delete'); + $clearCommandTester = new CommandTester($clearCommand); + $clearCommandTester->execute(['--indices' => 'posts']); + + $clearOutput = $clearCommandTester->getDisplay(); + + $this->assertSame(<<<'EOD' +Deleted sf_phpunit__posts +Done! + +EOD, $clearOutput); + } + + public function testAlias(): void + { + $command = $this->application->find('meilisearch:delete'); + + self::assertSame(['meili:delete'], $command->getAliases()); + } +} diff --git a/tests/Integration/Command/MeilisearchImportCommandTest.php b/tests/Integration/Command/MeilisearchImportCommandTest.php new file mode 100644 index 00000000..a9e09610 --- /dev/null +++ b/tests/Integration/Command/MeilisearchImportCommandTest.php @@ -0,0 +1,486 @@ +index = $this->client->index($this->getPrefix().self::$indexName); + $this->application = new Application(self::createKernel()); + } + + public function testImportWithoutUpdatingSettings(): void + { + for ($i = 0; $i <= 5; ++$i) { + $this->entityManager->persist(new Post()); + } + + $this->entityManager->flush(); + + $importCommand = $this->application->find('meilisearch:import'); + $importCommandTester = new CommandTester($importCommand); + $importCommandTester->execute(['--indices' => 'posts', '--no-update-settings' => true]); + + $importOutput = $importCommandTester->getDisplay(); + + $this->assertSame(<<<'EOD' +Importing for index Meilisearch\Bundle\Tests\Entity\Post +Indexed a batch of 6 / 6 Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__posts index (6 indexed since start) +Indexed a batch of 6 / 6 Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__aggregated index (6 indexed since start) +Done! + +EOD, $importOutput); + } + + public function testImportContentItem(): void + { + for ($i = 0; $i <= 5; ++$i) { + $this->entityManager->persist(new Article()); + } + + for ($i = 0; $i <= 5; ++$i) { + $this->entityManager->persist(new Podcast()); + } + + $this->entityManager->flush(); + + $importCommand = $this->application->find('meilisearch:import'); + $importCommandTester = new CommandTester($importCommand); + $importCommandTester->execute(['--indices' => 'discriminator_map', '--no-update-settings' => true]); + + $this->assertSame(<<<'EOD' +Importing for index Meilisearch\Bundle\Tests\Entity\ContentItem +Indexed a batch of 12 / 12 Meilisearch\Bundle\Tests\Entity\ContentItem entities into sf_phpunit__discriminator_map index (12 indexed since start) +Done! + +EOD, $importCommandTester->getDisplay()); + } + + public function testSearchImportWithCustomBatchSize(): void + { + for ($i = 0; $i <= 10; ++$i) { + $this->entityManager->persist(new Page(new DummyObjectId($i))); + } + + $this->entityManager->flush(); + + $importCommand = $this->application->find('meilisearch:import'); + $importCommandTester = new CommandTester($importCommand); + $importCommandTester->execute([ + '--indices' => 'pages', + '--batch-size' => '2', + ]); + + $this->assertSame(<<<'EOD' +Importing for index Meilisearch\Bundle\Tests\Entity\Page +Indexed a batch of 2 / 2 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index (2 indexed since start) +Indexed a batch of 2 / 2 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index (4 indexed since start) +Indexed a batch of 2 / 2 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index (6 indexed since start) +Indexed a batch of 2 / 2 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index (8 indexed since start) +Indexed a batch of 2 / 2 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index (10 indexed since start) +Indexed a batch of 1 / 1 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index (11 indexed since start) +Done! + +EOD, $importCommandTester->getDisplay()); + } + + public function testSearchImportWithCustomResponseTimeout(): void + { + for ($i = 0; $i < 10; ++$i) { + $this->entityManager->persist(new Page(new DummyObjectId($i))); + } + + $this->entityManager->flush(); + + $importCommand = $this->application->find('meilisearch:import'); + $importCommandTester = new CommandTester($importCommand); + $return = $importCommandTester->execute([ + '--indices' => 'pages', + '--response-timeout' => 10000, + ]); + $output = $importCommandTester->getDisplay(); + + $this->assertSame(<<<'EOD' +Importing for index Meilisearch\Bundle\Tests\Entity\Page +Indexed a batch of 10 / 10 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index (10 indexed since start) +Done! + +EOD, $output); + $this->assertSame(0, $return); + + // Reset all + parent::setUp(); + + for ($i = 0; $i < 10; ++$i) { + $this->entityManager->persist(new Page(new DummyObjectId($i))); + } + + $this->entityManager->flush(); + + // test if it will work with a bad option + $importCommand = $this->application->find('meilisearch:import'); + $importCommandTester = new CommandTester($importCommand); + $return = $importCommandTester->execute([ + '--indices' => 'pages', + '--response-timeout' => 'asd', + ]); + $output = $importCommandTester->getDisplay(); + + $this->assertSame(<<<'EOD' +Importing for index Meilisearch\Bundle\Tests\Entity\Page +Indexed a batch of 10 / 10 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index (10 indexed since start) +Done! + +EOD, $output); + $this->assertSame(0, $return); + } + + /** + * Importing 'Tag' and 'Link' into the same 'tags' index. + */ + public function testImportDifferentEntitiesIntoSameIndex(): void + { + for ($i = 0; $i <= 5; ++$i) { + $this->entityManager->persist(new Tag($i)); + } + + $this->entityManager->persist(new Link(60, 'Test Link 60', 'http://link60', true)); + $this->entityManager->persist(new Link(61, 'Test Link 61', 'http://link61', true)); + + $this->entityManager->flush(); + + $command = $this->application->find('meilisearch:import'); + $commandTester = new CommandTester($command); + $commandTester->execute(['--indices' => 'tags']); + + $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) +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! + +EOD, $commandTester->getDisplay()); + + $searchResult = $this->client->index($this->getPrefix().'tags')->search('Test'); + + $this->assertCount(8, $searchResult->getHits()); + $this->assertSame(8, $searchResult->getHitsCount()); + } + + public function testSearchImportAggregator(): void + { + for ($i = 0; $i <= 5; ++$i) { + $this->entityManager->persist(new Post()); + } + + $this->entityManager->flush(); + + $command = $this->application->find('meilisearch:import'); + $commandTester = new CommandTester($command); + $return = $commandTester->execute(['--indices' => $this->index->getUid()]); + + $this->assertSame(<<<'EOD' +Importing for index Meilisearch\Bundle\Tests\Entity\Post +Setting "stopWords" updated of "sf_phpunit__posts". +Setting "filterableAttributes" updated of "sf_phpunit__posts". +Setting "searchCutoffMs" updated of "sf_phpunit__posts". +Setting "typoTolerance" updated of "sf_phpunit__posts". +Indexed a batch of 6 / 6 Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__posts index (6 indexed since start) +Indexed a batch of 6 / 6 Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__aggregated index (6 indexed since start) +Done! + +EOD, $commandTester->getDisplay()); + + $this->assertSame(0, $return); + } + + public function testSearchImportWithSkipBatches(): void + { + for ($i = 0; $i < 10; ++$i) { + $this->entityManager->persist(new Page(new DummyObjectId($i))); + } + + $this->entityManager->flush(); + + $command = $this->application->find('meilisearch:import'); + $commandTester = new CommandTester($command); + $return = $commandTester->execute([ + '--indices' => 'pages', + '--batch-size' => '3', + '--skip-batches' => '2', + ]); + + $this->assertSame(<<<'EOD' +Importing for index Meilisearch\Bundle\Tests\Entity\Page +Skipping first 2 batches (6 records) +Indexed a batch of 3 / 3 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index (3 indexed since start) +Indexed a batch of 1 / 1 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index (4 indexed since start) +Done! + +EOD, $commandTester->getDisplay()); + $this->assertSame(0, $return); + } + + public function testImportingIndexNameWithAndWithoutPrefix(): void + { + for ($i = 0; $i <= 5; ++$i) { + $this->entityManager->persist(new Post()); + } + + $this->entityManager->flush(); + + $command = $this->application->find('meilisearch:import'); + $commandTester = new CommandTester($command); + $return = $commandTester->execute([ + '--indices' => $this->index->getUid(), // This is the already prefixed name + ]); + + $this->assertSame(<<<'EOD' +Importing for index Meilisearch\Bundle\Tests\Entity\Post +Setting "stopWords" updated of "sf_phpunit__posts". +Setting "filterableAttributes" updated of "sf_phpunit__posts". +Setting "searchCutoffMs" updated of "sf_phpunit__posts". +Setting "typoTolerance" updated of "sf_phpunit__posts". +Indexed a batch of 6 / 6 Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__posts index (6 indexed since start) +Indexed a batch of 6 / 6 Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__aggregated index (6 indexed since start) +Done! + +EOD, $commandTester->getDisplay()); + $this->assertSame(0, $return); + + // Reset database and MS indexes + parent::setUp(); + + for ($i = 0; $i <= 5; ++$i) { + $this->entityManager->persist(new Post()); + } + + $this->entityManager->flush(); + + $command = $this->application->find('meilisearch:import'); + $commandTester = new CommandTester($command); + $return = $commandTester->execute([ + '--indices' => self::$indexName, + ]); + + $output = $commandTester->getDisplay(); + $this->assertSame(<<<'EOD' +Importing for index Meilisearch\Bundle\Tests\Entity\Post +Setting "stopWords" updated of "sf_phpunit__posts". +Setting "filterableAttributes" updated of "sf_phpunit__posts". +Setting "searchCutoffMs" updated of "sf_phpunit__posts". +Setting "typoTolerance" updated of "sf_phpunit__posts". +Indexed a batch of 6 / 6 Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__posts index (6 indexed since start) +Indexed a batch of 6 / 6 Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__aggregated index (6 indexed since start) +Done! + +EOD, $commandTester->getDisplay()); + $this->assertSame(0, $return); + } + + public function testImportsSelfNormalizable(): void + { + for ($i = 1; $i <= 2; ++$i) { + $this->entityManager->persist(new SelfNormalizable($i, "Self normalizabie $i")); + } + + $this->entityManager->flush(); + + $importCommand = $this->application->find('meilisearch:import'); + $importCommandTester = new CommandTester($importCommand); + $importCommandTester->execute(['--indices' => 'self_normalizable']); + + $importOutput = $importCommandTester->getDisplay(); + + $this->assertSame(<<<'EOD' +Importing for index Meilisearch\Bundle\Tests\Entity\SelfNormalizable +Indexed a batch of 2 / 2 Meilisearch\Bundle\Tests\Entity\SelfNormalizable entities into sf_phpunit__self_normalizable index (2 indexed since start) +Done! + +EOD, $importOutput); + + self::assertSame([ + [ + 'objectID' => 1, + 'id' => 1, + 'name' => 'this test is correct', + 'self_normalized' => true, + ], + [ + 'objectID' => 2, + 'id' => 2, + 'name' => 'this test is correct', + 'self_normalized' => true, + ], + ], $this->client->index('sf_phpunit__self_normalizable')->getDocuments()->getResults()); + } + + public function testImportsDummyWithCustomGroups(): void + { + for ($i = 1; $i <= 2; ++$i) { + $this->entityManager->persist(new DummyCustomGroups($i, "Dummy $i", new \DateTimeImmutable('2024-04-04 07:32:0'.$i))); + } + + $this->entityManager->flush(); + + $importCommand = $this->application->find('meilisearch:import'); + $importCommandTester = new CommandTester($importCommand); + $importCommandTester->execute(['--indices' => 'dummy_custom_groups']); + + $importOutput = $importCommandTester->getDisplay(); + + $this->assertSame(<<<'EOD' +Importing for index Meilisearch\Bundle\Tests\Entity\DummyCustomGroups +Indexed a batch of 2 / 2 Meilisearch\Bundle\Tests\Entity\DummyCustomGroups entities into sf_phpunit__dummy_custom_groups index (2 indexed since start) +Done! + +EOD, $importOutput); + + self::assertSame([ + [ + 'objectID' => 1, + 'id' => 1, + 'name' => 'Dummy 1', + 'createdAt' => 1712215921, + ], + [ + 'objectID' => 2, + 'id' => 2, + 'name' => 'Dummy 2', + 'createdAt' => 1712215922, + ], + ], $this->client->index('sf_phpunit__dummy_custom_groups')->getDocuments()->getResults()); + } + + public function testImportWithDynamicSettings(): void + { + for ($i = 0; $i <= 5; ++$i) { + $this->entityManager->persist(new DynamicSettings($i, "Dynamic $i")); + } + + $this->entityManager->flush(); + + $importCommand = $this->application->find('meilisearch:import'); + $importCommandTester = new CommandTester($importCommand); + $importCommandTester->execute(['--indices' => 'dynamic_settings']); + + $importOutput = $importCommandTester->getDisplay(); + + $this->assertSame(<<<'EOD' +Importing for index Meilisearch\Bundle\Tests\Entity\DynamicSettings +Setting "filterableAttributes" updated of "sf_phpunit__dynamic_settings". +Setting "searchableAttributes" updated of "sf_phpunit__dynamic_settings". +Setting "stopWords" updated of "sf_phpunit__dynamic_settings". +Setting "synonyms" updated of "sf_phpunit__dynamic_settings". +Indexed a batch of 6 / 6 Meilisearch\Bundle\Tests\Entity\DynamicSettings entities into sf_phpunit__dynamic_settings index (6 indexed since start) +Done! + +EOD, $importOutput); + + $settings = $this->get('meilisearch.client')->index('sf_phpunit__dynamic_settings')->getSettings(); + + $getSetting = static fn ($value) => $value instanceof \IteratorAggregate ? iterator_to_array($value) : $value; + + $filterableAttributes = $getSetting($settings['filterableAttributes']); + sort($filterableAttributes); + $expected = ['publishedAt', 'title']; + sort($expected); + self::assertSame($expected, $filterableAttributes); + self::assertSame(['title'], $getSetting($settings['searchableAttributes'])); + self::assertSame(['a', 'n', 'the'], $getSetting($settings['stopWords'])); + self::assertSame(['fantastic' => ['great'], 'great' => ['fantastic']], $getSetting($settings['synonyms'])); + } + + public function testImportUpdatesSettingsOnce(): void + { + for ($i = 0; $i <= 3; ++$i) { + $this->entityManager->persist(new Post()); + } + + $this->entityManager->flush(); + + $importCommand = $this->application->find('meilisearch:import'); + $importCommandTester = new CommandTester($importCommand); + $importCommandTester->execute(['--indices' => 'posts', '--batch-size' => '2']); + + $importOutput = $importCommandTester->getDisplay(); + + $this->assertSame(<<<'EOD' +Importing for index Meilisearch\Bundle\Tests\Entity\Post +Setting "stopWords" updated of "sf_phpunit__posts". +Setting "filterableAttributes" updated of "sf_phpunit__posts". +Setting "searchCutoffMs" updated of "sf_phpunit__posts". +Setting "typoTolerance" updated of "sf_phpunit__posts". +Indexed a batch of 2 / 2 Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__posts index (2 indexed since start) +Indexed a batch of 2 / 2 Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__aggregated index (2 indexed since start) +Indexed a batch of 2 / 2 Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__posts index (4 indexed since start) +Indexed a batch of 2 / 2 Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__aggregated index (4 indexed since start) +Done! + +EOD, $importOutput); + } + + public function testAlias(): void + { + $command = $this->application->find('meilisearch:import'); + + self::assertSame(['meili:import'], $command->getAliases()); + } + + public function testImportingIndexWithSwap(): void + { + for ($i = 0; $i <= 5; ++$i) { + $this->entityManager->persist(new Post()); + } + + $this->entityManager->flush(); + + $command = $this->application->find('meilisearch:import'); + $commandTester = new CommandTester($command); + $return = $commandTester->execute([ + '--indices' => 'posts', + '--swap-indices' => true, + '--no-update-settings' => true, + ]); + + $this->assertSame(<<<'EOD' +Importing for index Meilisearch\Bundle\Tests\Entity\Post +Indexed a batch of 6 / 6 Meilisearch\Bundle\Tests\Entity\Post entities into _tmp_sf_phpunit__posts index (6 indexed since start) +Indexed a batch of 6 / 6 Meilisearch\Bundle\Tests\Entity\Post entities into _tmp_sf_phpunit__aggregated index (6 indexed since start) +Swapping indices... +Indices swapped. +Deleting temporary indices... +Deleted _tmp_sf_phpunit__posts +Done! + +EOD, $commandTester->getDisplay()); + $this->assertSame(0, $return); + } +} diff --git a/tests/Integration/Command/MeilisearchUpdateSettingsCommandTest.php b/tests/Integration/Command/MeilisearchUpdateSettingsCommandTest.php new file mode 100644 index 00000000..8b4b409f --- /dev/null +++ b/tests/Integration/Command/MeilisearchUpdateSettingsCommandTest.php @@ -0,0 +1,74 @@ +application = new Application(self::createKernel()); + } + + public function testWithoutIndices(): void + { + for ($i = 0; $i <= 5; ++$i) { + $this->entityManager->persist(new Post()); + } + + $this->entityManager->flush(); + + $importCommand = $this->application->find('meilisearch:update-settings'); + $importCommandTester = new CommandTester($importCommand); + $importCommandTester->execute([]); + + $importOutput = $importCommandTester->getDisplay(); + + $this->assertSame(<<<'EOD' +Setting "stopWords" updated of "sf_phpunit__posts". +Setting "filterableAttributes" updated of "sf_phpunit__posts". +Setting "searchCutoffMs" updated of "sf_phpunit__posts". +Setting "typoTolerance" updated of "sf_phpunit__posts". +Setting "filterableAttributes" updated of "sf_phpunit__dynamic_settings". +Setting "searchableAttributes" updated of "sf_phpunit__dynamic_settings". +Setting "stopWords" updated of "sf_phpunit__dynamic_settings". +Setting "synonyms" updated of "sf_phpunit__dynamic_settings". +Done! + +EOD, $importOutput); + } + + public function testWithIndices(): void + { + for ($i = 0; $i <= 5; ++$i) { + $this->entityManager->persist(new Post()); + } + + $this->entityManager->flush(); + + $importCommand = $this->application->find('meilisearch:update-settings'); + $importCommandTester = new CommandTester($importCommand); + $importCommandTester->execute(['--indices' => 'posts']); + + $importOutput = $importCommandTester->getDisplay(); + + $this->assertSame(<<<'EOD' +Setting "stopWords" updated of "sf_phpunit__posts". +Setting "filterableAttributes" updated of "sf_phpunit__posts". +Setting "searchCutoffMs" updated of "sf_phpunit__posts". +Setting "typoTolerance" updated of "sf_phpunit__posts". +Done! + +EOD, $importOutput); + } +} diff --git a/tests/Integration/CommandsTest.php b/tests/Integration/CommandsTest.php deleted file mode 100644 index 05765dd5..00000000 --- a/tests/Integration/CommandsTest.php +++ /dev/null @@ -1,375 +0,0 @@ -client = $this->get('search.client'); - $this->index = $this->client->index($this->getPrefix().self::$indexName); - $this->application = new Application(self::createKernel()); - } - - public function testSearchClearUnknownIndex(): void - { - $unknownIndexName = 'test'; - - $command = $this->application->find('meili:clear'); - $commandTester = new CommandTester($command); - - $commandTester->execute([ - '--indices' => $unknownIndexName, - ]); - - $output = $commandTester->getDisplay(); - $this->assertStringContainsString('Cannot clear index. Not found.', $output); - } - - public function testSearchImportAndClearAndDeleteWithoutIndices(): void - { - for ($i = 0; $i <= 5; ++$i) { - $this->createPost(); - } - - for ($i = 0; $i <= 5; ++$i) { - $this->createPage($i); - } - - for ($i = 0; $i <= 5; ++$i) { - $this->createTag(['id' => $i]); - } - - $importCommand = $this->application->find('meili:import'); - $importCommandTester = new CommandTester($importCommand); - $importCommandTester->execute([]); - - $importOutput = $importCommandTester->getDisplay(); - - $this->assertSame(<<<'EOD' -Importing for index Meilisearch\Bundle\Tests\Entity\Post -Indexed 6 / 6 Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__posts index -Indexed 6 / 6 Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__aggregated index -Settings updated. -Settings updated. -Settings updated. -Importing for index Meilisearch\Bundle\Tests\Entity\Comment -Importing for index Meilisearch\Bundle\Tests\Entity\Tag -Indexed 6 / 6 Meilisearch\Bundle\Tests\Entity\Tag entities into sf_phpunit__tags index -Indexed 6 / 6 Meilisearch\Bundle\Tests\Entity\Tag entities into sf_phpunit__aggregated index -Importing for index Meilisearch\Bundle\Tests\Entity\Link -Importing for index Meilisearch\Bundle\Tests\Entity\Page -Indexed 6 / 6 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index -Importing for index Meilisearch\Bundle\Tests\Entity\SelfNormalizable -Importing for index Meilisearch\Bundle\Tests\Entity\Post -Indexed 6 / 6 Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__posts index -Indexed 6 / 6 Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__aggregated index -Importing for index Meilisearch\Bundle\Tests\Entity\Tag -Indexed 6 / 6 Meilisearch\Bundle\Tests\Entity\Tag entities into sf_phpunit__tags index -Indexed 6 / 6 Meilisearch\Bundle\Tests\Entity\Tag entities into sf_phpunit__aggregated index -Done! - -EOD, $importOutput); - - $clearCommand = $this->application->find('meili:clear'); - $clearCommandTester = new CommandTester($clearCommand); - $clearCommandTester->execute([]); - - $clearOutput = $clearCommandTester->getDisplay(); - - $this->assertSame(<<<'EOD' -Cleared sf_phpunit__posts index of Meilisearch\Bundle\Tests\Entity\Post -Cleared sf_phpunit__comments index of Meilisearch\Bundle\Tests\Entity\Comment -Cleared sf_phpunit__aggregated index of Meilisearch\Bundle\Tests\Entity\ContentAggregator -Cleared sf_phpunit__tags index of Meilisearch\Bundle\Tests\Entity\Tag -Cleared sf_phpunit__tags index of Meilisearch\Bundle\Tests\Entity\Link -Cleared sf_phpunit__pages index of Meilisearch\Bundle\Tests\Entity\Page -Cleared sf_phpunit__self_normalizable index of Meilisearch\Bundle\Tests\Entity\SelfNormalizable -Done! - -EOD, $clearOutput); - - $clearCommand = $this->application->find('meili:delete'); - $clearCommandTester = new CommandTester($clearCommand); - $clearCommandTester->execute([]); - - $clearOutput = $clearCommandTester->getDisplay(); - - $this->assertSame(<<<'EOD' -Deleted sf_phpunit__posts -Deleted sf_phpunit__comments -Deleted sf_phpunit__aggregated -Deleted sf_phpunit__tags -Deleted sf_phpunit__pages -Deleted sf_phpunit__self_normalizable -Done! - -EOD, $clearOutput); - } - - public function testSearchImportWithCustomBatchSize(): void - { - for ($i = 0; $i <= 10; ++$i) { - $this->createPage($i); - } - - $importCommand = $this->application->find('meili:import'); - $importCommandTester = new CommandTester($importCommand); - $importCommandTester->execute([ - '--indices' => 'pages', - '--batch-size' => '2', - ]); - - $importOutput = $importCommandTester->getDisplay(); - - $this->assertSame(<<<'EOD' -Importing for index Meilisearch\Bundle\Tests\Entity\Page -Indexed 2 / 2 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index -Indexed 2 / 2 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index -Indexed 2 / 2 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index -Indexed 2 / 2 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index -Indexed 2 / 2 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index -Indexed 1 / 1 Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index -Done! - -EOD, $importOutput); - } - - public function testSearchImportWithCustomResponseTimeout(): void - { - for ($i = 0; $i < 10; ++$i) { - $this->createPage($i); - } - - $importCommand = $this->application->find('meili:import'); - $importCommandTester = new CommandTester($importCommand); - $return = $importCommandTester->execute([ - '--indices' => 'pages', - '--response-timeout' => 10000, - ]); - $output = $importCommandTester->getDisplay(); - - $this->assertStringContainsString('Importing for index Meilisearch\Bundle\Tests\Entity\Page', $output); - $this->assertStringContainsString('Indexed '.$i.' / '.$i.' Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index', $output); - $this->assertStringContainsString('Done!', $output); - $this->assertSame(0, $return); - - // Reset all - parent::setUp(); - - for ($i = 0; $i < 10; ++$i) { - $this->createPage($i); - } - - // test if it will work with a bad option - $importCommand = $this->application->find('meili:import'); - $importCommandTester = new CommandTester($importCommand); - $return = $importCommandTester->execute([ - '--indices' => 'pages', - '--response-timeout' => 'asd', - ]); - $output = $importCommandTester->getDisplay(); - - $this->assertStringContainsString('Importing for index Meilisearch\Bundle\Tests\Entity\Page', $output); - $this->assertStringContainsString('Indexed '.$i.' / '.$i.' Meilisearch\Bundle\Tests\Entity\Page entities into sf_phpunit__pages index', $output); - $this->assertStringContainsString('Done!', $output); - $this->assertSame(0, $return); - } - - /** - * Importing 'Tag' and 'Link' into the same 'tags' index. - */ - public function testImportDifferentEntitiesIntoSameIndex(): void - { - for ($i = 0; $i <= 5; ++$i) { - $this->createTag(['id' => $i]); - } - $this->createLink(['id' => 60, 'isSponsored' => true]); - $this->createLink(['id' => 61, 'isSponsored' => true]); - - $command = $this->application->find('meili:import'); - $commandTester = new CommandTester($command); - $commandTester->execute([ - '--indices' => 'tags', - ]); - - $output = $commandTester->getDisplay(); - $this->assertStringContainsString('Importing for index Meilisearch\Bundle\Tests\Entity\Tag', $output); - $this->assertStringContainsString('Indexed '.$i.' / '.$i.' Meilisearch\Bundle\Tests\Entity\Tag entities into sf_phpunit__tags index', $output); - $this->assertStringContainsString('Indexed 2 / 2 Meilisearch\Bundle\Tests\Entity\Link entities into sf_phpunit__tags index', $output); - $this->assertStringContainsString('Done!', $output); - - /** @var SearchResult $searchResult */ - $searchResult = $this->client->index($this->getPrefix().'tags')->search('Test'); - $this->assertCount(8, $searchResult->getHits()); - $this->assertSame(8, $searchResult->getHitsCount()); - } - - public function testSearchImportAggregator(): void - { - for ($i = 0; $i <= 5; ++$i) { - $this->createPost(); - } - - $command = $this->application->find('meili:import'); - $commandTester = new CommandTester($command); - $return = $commandTester->execute([ - '--indices' => $this->index->getUid(), - ]); - - $output = $commandTester->getDisplay(); - $this->assertStringContainsString('Importing for index Meilisearch\Bundle\Tests\Entity\Post', $output); - $this->assertStringContainsString('Indexed '.$i.' / '.$i.' Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__'.self::$indexName.' index', $output); - $this->assertStringContainsString('Done!', $output); - $this->assertSame(0, $return); - } - - public function testImportingIndexNameWithAndWithoutPrefix(): void - { - for ($i = 0; $i <= 5; ++$i) { - $this->createPost(); - } - - $command = $this->application->find('meili:import'); - $commandTester = new CommandTester($command); - $return = $commandTester->execute([ - '--indices' => $this->index->getUid(), // This is the already prefixed name - ]); - - $output = $commandTester->getDisplay(); - $this->assertStringContainsString('Importing for index Meilisearch\Bundle\Tests\Entity\Post', $output); - $this->assertStringContainsString('Indexed '.$i.' / '.$i.' Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__'.self::$indexName.' index', $output); - $this->assertStringContainsString('Done!', $output); - $this->assertSame(0, $return); - - // Reset database and MS indexes - parent::setUp(); - - for ($i = 0; $i <= 5; ++$i) { - $this->createPost(); - } - - $command = $this->application->find('meili:import'); - $commandTester = new CommandTester($command); - $return = $commandTester->execute([ - '--indices' => self::$indexName, // This is the already prefixed name - ]); - - $output = $commandTester->getDisplay(); - $this->assertStringContainsString('Importing for index Meilisearch\Bundle\Tests\Entity\Post', $output); - $this->assertStringContainsString('Indexed '.$i.' / '.$i.' Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__'.self::$indexName.' index', $output); - $this->assertStringContainsString('Done!', $output); - $this->assertSame(0, $return); - } - - public function testSearchCreateWithoutIndices(): void - { - $createCommand = $this->application->find('meili:create'); - $createCommandTester = new CommandTester($createCommand); - $createCommandTester->execute([]); - - $createOutput = $createCommandTester->getDisplay(); - - $this->assertSame(<<<'EOD' -Creating index sf_phpunit__posts for Meilisearch\Bundle\Tests\Entity\Post -Creating index sf_phpunit__comments for Meilisearch\Bundle\Tests\Entity\Comment -Creating index sf_phpunit__tags for Meilisearch\Bundle\Tests\Entity\Tag -Creating index sf_phpunit__tags for Meilisearch\Bundle\Tests\Entity\Link -Creating index sf_phpunit__pages for Meilisearch\Bundle\Tests\Entity\Page -Creating index sf_phpunit__self_normalizable for Meilisearch\Bundle\Tests\Entity\SelfNormalizable -Creating index sf_phpunit__aggregated for Meilisearch\Bundle\Tests\Entity\Post -Creating index sf_phpunit__aggregated for Meilisearch\Bundle\Tests\Entity\Tag -Done! - -EOD, $createOutput); - } - - public function testSearchCreateWithIndices(): void - { - $createCommand = $this->application->find('meili:create'); - $createCommandTester = new CommandTester($createCommand); - $createCommandTester->execute([ - '--indices' => 'posts', - ]); - - $createOutput = $createCommandTester->getDisplay(); - - $this->assertSame(<<<'EOD' -Creating index sf_phpunit__posts for Meilisearch\Bundle\Tests\Entity\Post -Done! - -EOD, $createOutput); - } - - public function testCreateExecuteIndexCreation(): void - { - $createCommand = $this->application->find('meili:create'); - $createCommandTester = new CommandTester($createCommand); - $createCommandTester->execute([]); - - $this->assertEquals($this->client->getTasks()->getResults()[0]['type'], 'indexCreation'); - } - - public function testImportsSelfNormalizable(): void - { - for ($i = 1; $i <= 2; ++$i) { - $this->entityManager->persist(new SelfNormalizable($i, "Self normalizabie $i")); - } - - $this->entityManager->flush(); - - $importCommand = $this->application->find('meili:import'); - $importCommandTester = new CommandTester($importCommand); - $importCommandTester->execute(['--indices' => 'self_normalizable']); - - $importOutput = $importCommandTester->getDisplay(); - - $this->assertSame(<<<'EOD' -Importing for index Meilisearch\Bundle\Tests\Entity\SelfNormalizable -Indexed 2 / 2 Meilisearch\Bundle\Tests\Entity\SelfNormalizable entities into sf_phpunit__self_normalizable index -Done! - -EOD, $importOutput); - - self::assertSame([ - [ - 'objectID' => 1, - 'id' => 1, - 'name' => 'this test is correct', - 'self_normalized' => true, - ], - [ - 'objectID' => 2, - 'id' => 2, - 'name' => 'this test is correct', - 'self_normalized' => true, - ], - ], $this->client->index('sf_phpunit__self_normalizable')->getDocuments()->getResults()); - } -} diff --git a/tests/Integration/DependencyInjectionTest.php b/tests/Integration/DependencyInjectionTest.php index d5588cec..a545a585 100644 --- a/tests/Integration/DependencyInjectionTest.php +++ b/tests/Integration/DependencyInjectionTest.php @@ -2,11 +2,13 @@ declare(strict_types=1); +namespace Meilisearch\Bundle\Tests\Integration; + use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractExtensionTestCase; use Meilisearch\Bundle\DependencyInjection\MeilisearchExtension; use Meilisearch\Bundle\MeilisearchBundle; -class MeilisearchExtensionTest extends AbstractExtensionTestCase +final class DependencyInjectionTest extends AbstractExtensionTestCase { protected function getContainerExtensions(): array { @@ -17,14 +19,14 @@ protected function getContainerExtensions(): array public function testHasMeilisearchVersionDefinitionAfterLoad(): void { - $this->load(); + $this->load(['url' => 'http://meilisearch:7700', 'api_key' => null]); - $this->assertContainerBuilderHasServiceDefinitionWithArgument('search.client', '$clientAgents', ['%meili_symfony_version%']); + $this->assertContainerBuilderHasServiceDefinitionWithArgument('meilisearch.client', 4, [MeilisearchBundle::qualifiedVersion()]); } public function testHasMeilisearchVersionFromConstantAfterLoad(): void { - $this->load(); + $this->load(['url' => 'http://meilisearch:7700', 'api_key' => null]); $this->assertContainerBuilderHasParameter('meili_symfony_version', MeilisearchBundle::qualifiedVersion()); } diff --git a/tests/Integration/EngineTest.php b/tests/Integration/EngineTest.php index ca0b36a0..483f3bf2 100644 --- a/tests/Integration/EngineTest.php +++ b/tests/Integration/EngineTest.php @@ -5,13 +5,13 @@ namespace Meilisearch\Bundle\Tests\Integration; use Meilisearch\Bundle\Engine; +use Meilisearch\Bundle\SearchableEntity; use Meilisearch\Bundle\Tests\BaseKernelTestCase; +use Meilisearch\Bundle\Tests\Entity\Image; +use Meilisearch\Bundle\Tests\Entity\Post; use Meilisearch\Exceptions\ApiException; -/** - * Class EngineTest. - */ -class EngineTest extends BaseKernelTestCase +final class EngineTest extends BaseKernelTestCase { protected Engine $engine; @@ -19,7 +19,7 @@ public function setUp(): void { parent::setUp(); - $this->engine = new Engine($this->get('search.client')); + $this->engine = new Engine($this->get('meilisearch.client')); } /** @@ -27,15 +27,21 @@ public function setUp(): void */ public function testIndexingEmptyEntity(): void { - $searchableImage = $this->createSearchableImage(); + $image = new Image(); - // Index - $result = $this->engine->index($searchableImage); - $this->assertEmpty($result); + $this->entityManager->persist($image); + $this->entityManager->flush(); + + $searchableImage = new SearchableEntity( + $this->getPrefix().'image', + $image, + $this->get('doctrine')->getManager()->getClassMetadata(Image::class), + null + ); // Remove $result = $this->engine->remove($searchableImage); - $this->assertEmpty($result); + $this->assertArrayHasKey('sf_phpunit__image', $result); // Update $result = $this->engine->index($searchableImage); @@ -48,4 +54,33 @@ public function testIndexingEmptyEntity(): void $this->assertInstanceOf(ApiException::class, $e); } } + + 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); + + $result = $this->engine->remove([$postSearchable1, $postSearchable2]); + + $this->assertArrayHasKey('sf_phpunit__posts', $result); + $this->assertCount(2, $result['sf_phpunit__posts']); + + $this->waitForAllTasks(); + + 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 edeef413..8899cb49 100644 --- a/tests/Integration/EventListener/DoctrineEventSubscriberTest.php +++ b/tests/Integration/EventListener/DoctrineEventSubscriberTest.php @@ -4,126 +4,103 @@ namespace Meilisearch\Bundle\Tests\Integration\EventListener; -use Doctrine\ORM\Event\LifecycleEventArgs; -use Meilisearch\Bundle\EventListener\DoctrineEventSubscriber; use Meilisearch\Bundle\Tests\BaseKernelTestCase; +use Meilisearch\Bundle\Tests\Entity\ObjectId\DummyObjectId; use Meilisearch\Bundle\Tests\Entity\Page; use Meilisearch\Bundle\Tests\Entity\Post; -use Meilisearch\Client; -class DoctrineEventSubscriberTest extends BaseKernelTestCase +final class DoctrineEventSubscriberTest extends BaseKernelTestCase { - protected Client $client; - - /** - * @throws \Exception - */ - public function setUp(): void - { - parent::setUp(); - - $this->client = $this->get('search.client'); - } - - /** - * This tests creates two posts in the database, but only one is triggered via an event to Meilisearch. - */ public function testPostPersist(): void { - $this->createPost(); - $post = $this->createPost(); + $post = new Post('Test Post'); - $eventArgs = new LifecycleEventArgs($post, $this->entityManager); - - $subscriber = new DoctrineEventSubscriber($this->searchService, []); - $subscriber->postPersist($eventArgs); + $this->entityManager->persist($post); + $this->entityManager->flush(); $this->waitForAllTasks(); $result = $this->searchService->search($this->entityManager, Post::class, $post->getTitle()); $this->assertCount(1, $result); - $this->assertSame(2, $result[0]->getId()); + $this->assertSame($post->getId(), $result[0]->getId()); } public function testPostPersistWithObjectId(): void { - $this->createPage(1); - $page = $this->createPage(2); - - $eventArgs = new LifecycleEventArgs($page, $this->entityManager); + $page = new Page(new DummyObjectId(1)); - $subscriber = new DoctrineEventSubscriber($this->searchService, []); - $subscriber->postPersist($eventArgs); + $this->entityManager->persist($page); + $this->entityManager->flush(); $this->waitForAllTasks(); $result = $this->searchService->search($this->entityManager, Page::class, $page->getTitle()); $this->assertCount(1, $result); - $this->assertSame((string) $page->getId(), (string) $result[0]->getId()); + $this->assertEquals(new DummyObjectId(1), $result[0]->getId()); } - /** - * This tests creates two posts in the database, but only one is triggered via an event to Meilisearch. - */ public function testPostUpdate(): void { - $this->createPost(); - $post = $this->createPost(); + $post = new Post(); + + $this->entityManager->persist($post); + $this->entityManager->flush(); + + $this->waitForAllTasks(); - $eventArgs = new LifecycleEventArgs($post, $this->entityManager); + $post->setTitle('Better post'); - $subscriber = new DoctrineEventSubscriber($this->searchService, []); - $subscriber->postUpdate($eventArgs); + $this->entityManager->flush(); $this->waitForAllTasks(); - $result = $this->searchService->search($this->entityManager, Post::class, $post->getTitle()); + $result = $this->searchService->search($this->entityManager, Post::class, 'better'); $this->assertCount(1, $result); - $this->assertSame(2, $result[0]->getId()); + $this->assertSame($post->getId(), $result[0]->getId()); + $this->assertSame('Better post', $result[0]->getTitle()); } public function testPostUpdateWithObjectId(): void { - $this->createPage(1); - $page = $this->createPage(2); + $page = new Page(new DummyObjectId(1)); + + $this->entityManager->persist($page); + $this->entityManager->flush(); - $eventArgs = new LifecycleEventArgs($page, $this->entityManager); + $this->waitForAllTasks(); - $subscriber = new DoctrineEventSubscriber($this->searchService, []); - $subscriber->postUpdate($eventArgs); + $page->setTitle('Better page'); + + $this->entityManager->flush(); $this->waitForAllTasks(); - $result = $this->searchService->search($this->entityManager, Page::class, $page->getTitle()); + $result = $this->searchService->search($this->entityManager, Page::class, 'better'); $this->assertCount(1, $result); - $this->assertSame((string) $page->getId(), (string) $result[0]->getId()); + $this->assertEquals(new DummyObjectId(1), $result[0]->getId()); + $this->assertSame('Better page', $result[0]->getTitle()); } - /** - * This tests creates posts in the database, send it to Meilisearch via a trigger. Afterwards Doctrines 'preRemove' event - * is going to remove that entity from MS. - */ public function testPreRemove(): void { - $post = $this->createPost(); + $post = new Post('Test Post'); - $eventArgs = new LifecycleEventArgs($post, $this->entityManager); - - $subscriber = new DoctrineEventSubscriber($this->searchService, []); - $subscriber->postPersist($eventArgs); + $this->entityManager->persist($post); + $this->entityManager->flush(); $this->waitForAllTasks(); $result = $this->searchService->search($this->entityManager, Post::class, $post->getTitle()); $this->assertCount(1, $result); - $this->assertSame(1, $result[0]->getId()); + $this->assertSame($post->getId(), $result[0]->getId()); - $subscriber->preRemove($eventArgs); + $this->entityManager->remove($post); + $this->entityManager->flush(); $this->waitForAllTasks(); @@ -134,21 +111,20 @@ public function testPreRemove(): void public function testPreRemoveWithObjectId(): void { - $page = $this->createPage(1); - - $eventArgs = new LifecycleEventArgs($page, $this->entityManager); + $page = new Page(new DummyObjectId(1)); - $subscriber = new DoctrineEventSubscriber($this->searchService, []); - $subscriber->postPersist($eventArgs); + $this->entityManager->persist($page); + $this->entityManager->flush(); $this->waitForAllTasks(); $result = $this->searchService->search($this->entityManager, Page::class, $page->getTitle()); $this->assertCount(1, $result); - $this->assertSame((string) $page->getId(), (string) $result[0]->getId()); + $this->assertEquals($page->getId(), $result[0]->getId()); - $subscriber->preRemove($eventArgs); + $this->entityManager->remove($page); + $this->entityManager->flush(); $this->waitForAllTasks(); @@ -156,13 +132,4 @@ public function testPreRemoveWithObjectId(): void $this->assertCount(0, $result); } - - /** - * Waits for all the tasks to be finished by checking the topest one (so the newest one). - */ - private function waitForAllTasks(): void - { - $firstTask = $this->client->getTasks()->getResults()[0]; - $this->client->waitForTask($firstTask['uid']); - } } diff --git a/tests/Integration/Fixtures/FilterableAttributes.php b/tests/Integration/Fixtures/FilterableAttributes.php new file mode 100644 index 00000000..c10b5783 --- /dev/null +++ b/tests/Integration/Fixtures/FilterableAttributes.php @@ -0,0 +1,15 @@ + ['fantastic'], + 'fantastic' => ['great'], + ]; + } +} diff --git a/tests/Integration/SearchTest.php b/tests/Integration/SearchTest.php index 6a606c57..db41ab77 100644 --- a/tests/Integration/SearchTest.php +++ b/tests/Integration/SearchTest.php @@ -9,36 +9,24 @@ use Meilisearch\Bundle\Tests\BaseKernelTestCase; use Meilisearch\Bundle\Tests\Entity\Post; use Meilisearch\Bundle\Tests\Entity\Tag; -use Meilisearch\Client; use Meilisearch\Endpoints\Indexes; -use Meilisearch\Exceptions\ApiException; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Tester\CommandTester; -/** - * Class SearchTest. - */ -class SearchTest extends BaseKernelTestCase +final class SearchTest extends BaseKernelTestCase { private static string $indexName = 'aggregated'; - protected Client $client; protected Connection $connection; protected ObjectManager $objectManager; protected Application $application; protected Indexes $index; - /** - * {@inheritDoc} - * - * @throws ApiException - * @throws \Exception - */ protected function setUp(): void { parent::setUp(); - $this->client = $this->get('search.client'); + $this->client = $this->get('meilisearch.client'); $this->objectManager = $this->get('doctrine')->getManager(); $this->index = $this->client->index($this->getPrefix().self::$indexName); $this->application = new Application(self::createKernel()); @@ -54,12 +42,17 @@ public function testSearchImportAggregator(): void $testDataTitles = []; for ($i = 0; $i < 5; ++$i) { - $testDataTitles[] = $this->createPost()->getTitle(); + $post = new Post('Test Post', 'Test content post'); + + $this->entityManager->persist($post); + + $testDataTitles[] = $post->getTitle(); } - $this->createTag(['id' => 99]); + $this->entityManager->persist(new Tag(99)); + $this->entityManager->flush(); - $command = $this->application->find('meili:import'); + $command = $this->application->find('meilisearch:import'); $commandTester = new CommandTester($command); $commandTester->execute([ '--indices' => $this->index->getUid(), @@ -69,10 +62,10 @@ public function testSearchImportAggregator(): void $this->assertStringContainsString('Importing for index Meilisearch\Bundle\Tests\Entity\Post', $output); $this->assertStringContainsString('Importing for index Meilisearch\Bundle\Tests\Entity\Tag', $output); - $this->assertStringContainsString('Indexed '.$i.' / '.$i.' Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__posts index', $output); - $this->assertStringContainsString('Indexed '.$i.' / '.$i.' Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__'.self::$indexName.' index', $output); - $this->assertStringContainsString('Indexed 1 / 1 Meilisearch\Bundle\Tests\Entity\Tag entities into sf_phpunit__tags index', $output); - $this->assertStringContainsString('Indexed 1 / 1 Meilisearch\Bundle\Tests\Entity\Tag entities into sf_phpunit__'.self::$indexName.' index', $output); + $this->assertStringContainsString('Indexed a batch of '.$i.' / '.$i.' Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__posts index ('.$i.' indexed since start)', $output); + $this->assertStringContainsString('Indexed a batch of '.$i.' / '.$i.' Meilisearch\Bundle\Tests\Entity\Post entities into sf_phpunit__'.self::$indexName.' index ('.$i.' indexed since start)', $output); + $this->assertStringContainsString('Indexed a batch of 1 / 1 Meilisearch\Bundle\Tests\Entity\Tag entities into sf_phpunit__tags index (1 indexed since start)', $output); + $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); $searchTerm = 'Test'; @@ -80,13 +73,13 @@ public function testSearchImportAggregator(): void $results = $this->searchService->search($this->objectManager, Post::class, $searchTerm); $this->assertCount(5, $results); - $resultTitles = array_map(fn (Post $post) => $post->getTitle(), $results); + $resultTitles = array_map(static fn (Post $post) => $post->getTitle(), $results); $this->assertEqualsCanonicalizing($testDataTitles, $resultTitles); $results = $this->searchService->rawSearch(Post::class, $searchTerm); $this->assertCount(5, $results['hits']); - $resultTitles = array_map(fn (array $hit) => $hit['title'], $results['hits']); + $resultTitles = array_map(static fn (array $hit) => $hit['title'], $results['hits']); $this->assertEqualsCanonicalizing($testDataTitles, $resultTitles); $this->assertCount(5, $results['hits']); @@ -101,10 +94,16 @@ public function testSearchPagination(): void $testDataTitles = []; for ($i = 0; $i < 5; ++$i) { - $testDataTitles[] = $this->createPost()->getTitle(); + $post = new Post('Test Post', 'Test content post'); + + $this->entityManager->persist($post); + + $testDataTitles[] = $post->getTitle(); } - $command = $this->application->find('meili:import'); + $this->entityManager->flush(); + + $command = $this->application->find('meilisearch:import'); $commandTester = new CommandTester($command); $commandTester->execute([ '--indices' => $this->index->getUid(), @@ -115,12 +114,26 @@ public function testSearchPagination(): void $results = $this->searchService->search($this->objectManager, Post::class, $searchTerm, ['page' => 2, 'hitsPerPage' => 2]); $this->assertCount(2, $results); - $resultTitles = array_map(fn (Post $post) => $post->getTitle(), $results); - $this->assertEqualsCanonicalizing(array_slice($testDataTitles, 2, 2), $resultTitles); + $resultTitles = array_map(static fn (Post $post) => $post->getTitle(), $results); + $this->assertEqualsCanonicalizing(\array_slice($testDataTitles, 2, 2), $resultTitles); } - protected function tearDown(): void + public function testSearchNbResults(): void { - parent::tearDown(); + for ($i = 0; $i < 15; ++$i) { + $this->entityManager->persist(new Post("Test Post $i", "Test content post $i")); + } + + $this->entityManager->flush(); + + $command = $this->application->find('meilisearch:import'); + $commandTester = new CommandTester($command); + $commandTester->execute([ + '--indices' => $this->index->getUid(), + ]); + + $results = $this->searchService->search($this->objectManager, Post::class, 'test'); + + $this->assertCount(12, $results); } } diff --git a/tests/Integration/SettingsTest.php b/tests/Integration/SettingsTest.php index 364d4e03..375e2ea5 100644 --- a/tests/Integration/SettingsTest.php +++ b/tests/Integration/SettingsTest.php @@ -5,39 +5,29 @@ namespace Meilisearch\Bundle\Tests\Integration; use Meilisearch\Bundle\Tests\BaseKernelTestCase; -use Meilisearch\Client; use Meilisearch\Contracts\Index\TypoTolerance; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Tester\CommandTester; -/** - * Class SettingsTest. - */ -class SettingsTest extends BaseKernelTestCase +final class SettingsTest extends BaseKernelTestCase { private static string $indexName = 'posts'; - public const DEFAULT_RANKING_RULES - = [ - 'words', - 'typo', - 'proximity', - 'attribute', - 'sort', - 'exactness', - ]; - - protected Client $client; + public const DEFAULT_RANKING_RULES = [ + 'words', + 'typo', + 'proximity', + 'attribute', + 'sort', + 'exactness', + ]; + protected Application $application; - /** - * @throws \Exception - */ public function setUp(): void { parent::setUp(); - $this->client = $this->get('search.client'); $this->application = new Application(self::$kernel); } @@ -45,7 +35,7 @@ public function testUpdateSettings(): void { $index = $this->getPrefix().self::$indexName; - $command = $this->application->find('meili:import'); + $command = $this->application->find('meilisearch:import'); $commandTester = new CommandTester($command); $commandTester->execute([ '--indices' => $index, @@ -55,18 +45,26 @@ public function testUpdateSettings(): void $settings = $this->client->index($index)->getSettings(); $output = $commandTester->getDisplay(); - $this->assertStringContainsString('Settings updated.', $output); - $this->assertNotEmpty($settings['stopWords']); - $this->assertEquals(['a', 'an', 'the'], $settings['stopWords']); - $this->assertNotEmpty($settings['filterableAttributes']); - $this->assertEquals(['publishedAt', 'title'], $settings['filterableAttributes']); + $this->assertStringContainsString('Setting "stopWords" updated of "sf_phpunit__posts".', $output); + $this->assertSame(['a', 'an', 'the'], $settings['stopWords']); + + $this->assertStringContainsString('Setting "searchCutoffMs" updated of "sf_phpunit__posts".', $output); + $this->assertSame(1500, $settings['searchCutoffMs']); + + $this->assertStringContainsString('Setting "filterableAttributes" updated of "sf_phpunit__posts".', $output); + $filterableAttributes = $settings['filterableAttributes']; + sort($filterableAttributes); + $expected = ['publishedAt', 'title']; + sort($expected); + $this->assertSame($expected, $filterableAttributes); + $this->assertStringContainsString('Setting "typoTolerance" updated of "sf_phpunit__posts".', $output); $this->assertArrayHasKey('typoTolerance', $settings); $this->assertInstanceOf(TypoTolerance::class, $settings['typoTolerance']); $this->assertTrue($settings['typoTolerance']['enabled']); - $this->assertEquals(['title'], $settings['typoTolerance']['disableOnAttributes']); - $this->assertEquals(['york'], $settings['typoTolerance']['disableOnWords']); - $this->assertEquals(['oneTypo' => 5, 'twoTypos' => 9], $settings['typoTolerance']['minWordSizeForTypos']); + $this->assertSame(['title'], $settings['typoTolerance']['disableOnAttributes']); + $this->assertSame(['york'], $settings['typoTolerance']['disableOnWords']); + $this->assertSame(['oneTypo' => 5, 'twoTypos' => 9], $settings['typoTolerance']['minWordSizeForTypos']); } } diff --git a/tests/Kernel.php b/tests/Kernel.php index 55952b4e..28b72424 100644 --- a/tests/Kernel.php +++ b/tests/Kernel.php @@ -4,36 +4,82 @@ namespace Meilisearch\Bundle\Tests; +use Doctrine\Bundle\DoctrineBundle\ConnectionFactory; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; +use Doctrine\ORM\Configuration; +use Doctrine\ORM\Mapping\LegacyReflectionFields; use Meilisearch\Bundle\MeilisearchBundle; +use Symfony\Bridge\Doctrine\ArgumentResolver\EntityValueResolver; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Component\Config\Loader\LoaderInterface; -use Symfony\Component\HttpKernel\Bundle\BundleInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Kernel as HttpKernel; -/** - * Class Kernel. - */ -class Kernel extends HttpKernel +final class Kernel extends HttpKernel { - /** - * {@inheritDoc} - * - * @return array - */ - public function registerBundles(): array + use MicroKernelTrait; + + public function registerBundles(): iterable { - return [ - new FrameworkBundle(), - new DoctrineBundle(), - new MeilisearchBundle(), - ]; + yield new FrameworkBundle(); + yield new DoctrineBundle(); + yield new MeilisearchBundle(); } - public function registerContainerConfiguration(LoaderInterface $loader): void + protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader): void { - $loader->load(__DIR__.'/config/config.yaml'); - $loader->load(__DIR__.'/../config/services.xml'); + if (PHP_VERSION_ID >= 80000) { + if (class_exists(LegacyReflectionFields::class) && PHP_VERSION_ID >= 80400) { + $loader->load(__DIR__.'/config/config.yaml'); + } else { + $loader->load(__DIR__.'/config/config_old_proxy.yaml'); + } + } else { + $loader->load(__DIR__.'/config/config_php7.yaml'); + } $loader->load(__DIR__.'/config/meilisearch.yaml'); + + if (\defined(ConnectionFactory::class.'::DEFAULT_SCHEME_MAP')) { + $container->prependExtensionConfig('doctrine', [ + 'orm' => [ + 'report_fields_where_declared' => true, + 'validate_xml_mapping' => true, + ], + ]); + } + + // @phpstan-ignore-next-line + if (method_exists(Configuration::class, 'setLazyGhostObjectEnabled') && Kernel::VERSION_ID >= 60100) { + $container->prependExtensionConfig('doctrine', [ + 'orm' => [ + 'enable_lazy_ghost_objects' => true, + ], + ]); + } + + if (class_exists(EntityValueResolver::class)) { + $container->prependExtensionConfig('doctrine', [ + 'orm' => [ + 'controller_resolver' => [ + 'auto_mapping' => false, + ], + ], + ]); + } + + // @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', [ + 'property_info' => ['with_constructor_extractor' => false], + ]); + } } } diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index d30713b0..a6789e07 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -4,62 +4,110 @@ namespace Meilisearch\Bundle\Tests\Unit; +use Matthias\SymfonyConfigTest\PhpUnit\ConfigurationTestCaseTrait; use Meilisearch\Bundle\DependencyInjection\Configuration; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; -/** - * Class ConfigurationTest. - */ -class ConfigurationTest extends KernelTestCase +final class ConfigurationTest extends KernelTestCase { + use ConfigurationTestCaseTrait; + /** - * @dataProvider dataTestConfigurationTree + * @param array $inputConfig + * @param array $expectedConfig * - * @param mixed $inputConfig - * @param mixed $expectedConfig + * @dataProvider dataTestConfigurationTree */ - public function testConfigurationTree($inputConfig, $expectedConfig): void + public function testValidConfig(array $inputConfig, array $expectedConfig): void { - $configuration = new Configuration(); - - $node = $configuration->getConfigTreeBuilder()->buildTree(); - $normalizedConfig = $node->normalize($inputConfig); - $finalizedConfig = $node->finalize($normalizedConfig); - - $this->assertEquals($expectedConfig, $finalizedConfig); + $this->assertProcessedConfigurationEquals($inputConfig, $expectedConfig); } - public function dataTestConfigurationTree(): array + /** + * @param mixed $value + * + * @dataProvider dataTestSettingsDynamicCheckerInvalid + */ + public function testSettingsDynamicCheckerInvalid($value): void { - return [ - 'test empty config for default value' => [ - [], - [ - 'prefix' => null, - 'nbResults' => 20, - 'batchSize' => 500, - 'serializer' => 'serializer', - 'doctrineSubscribedEvents' => ['postPersist', 'postUpdate', 'preRemove'], - 'indices' => [], + $this->assertConfigurationIsInvalid([ + 'meilisearch' => [ + 'indices' => [ + [ + 'name' => 'items', + 'class' => 'App\Entity\Post', + 'settings' => $value, + ], ], ], - 'Simple config' => [ - [ - 'prefix' => 'sf_', - 'nbResults' => 40, - 'batchSize' => 100, + ], 'Settings must be an array.'); + } + + /** + * @param mixed $value + * + * @dataProvider dataTestSettingsDynamicCheckerValid + */ + public function testSettingsDynamicCheckerValid($value): void + { + $this->assertConfigurationIsValid([ + 'meilisearch' => [ + 'indices' => [ + [ + 'name' => 'items', + 'class' => 'App\Entity\Post', + 'settings' => $value, + ], ], - [ + ], + ]); + } + + /** + * @return iterable, expectedConfig: array}> + */ + public static function dataTestConfigurationTree(): iterable + { + yield 'test empty config for default value' => [ + 'inputConfig' => [ + 'meilisearch' => [], + ], + 'expectedConfig' => [ + 'url' => 'http://localhost:7700', + 'prefix' => null, + 'nbResults' => 20, + 'batchSize' => 500, + 'serializer' => 'serializer', + 'doctrineSubscribedEvents' => ['postPersist', 'postUpdate', 'preRemove'], + 'http_client' => 'psr18.http_client', + 'indices' => [], + ], + ]; + + yield 'simple config' => [ + 'inputConfig' => [ + 'meilisearch' => [ + 'url' => 'http://meilisearch:7700', 'prefix' => 'sf_', 'nbResults' => 40, 'batchSize' => 100, - 'serializer' => 'serializer', - 'doctrineSubscribedEvents' => ['postPersist', 'postUpdate', 'preRemove'], - 'indices' => [], ], ], - 'Index config' => [ - [ + 'expectedConfig' => [ + 'url' => 'http://meilisearch:7700', + 'prefix' => 'sf_', + 'nbResults' => 40, + 'batchSize' => 100, + 'serializer' => 'serializer', + 'doctrineSubscribedEvents' => ['postPersist', 'postUpdate', 'preRemove'], + 'http_client' => 'psr18.http_client', + 'indices' => [], + ], + ]; + + yield 'index config' => [ + 'inputConfig' => [ + 'meilisearch' => [ 'prefix' => 'sf_', 'indices' => [ ['name' => 'posts', 'class' => 'App\Entity\Post', 'index_if' => null], @@ -71,62 +119,258 @@ public function dataTestConfigurationTree(): array ], ], ], - [ + ], + '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' => [], + ], + 1 => [ + 'name' => 'tags', + 'class' => 'App\Entity\Tag', + 'enable_serializer_groups' => true, + 'serializer_groups' => ['searchable'], + 'index_if' => null, + 'settings' => [], + ], + ], + ], + ]; + + yield 'same index for multiple models' => [ + 'inputConfig' => [ + 'meilisearch' => [ 'prefix' => 'sf_', - 'nbResults' => 20, - 'batchSize' => 500, - 'serializer' => 'serializer', - 'doctrineSubscribedEvents' => ['postPersist', 'postUpdate', 'preRemove'], 'indices' => [ - 0 => [ - 'name' => 'posts', + [ + 'name' => 'items', 'class' => 'App\Entity\Post', 'enable_serializer_groups' => false, 'index_if' => null, 'settings' => [], ], - 1 => [ - 'name' => 'tags', + [ + 'name' => 'items', 'class' => 'App\Entity\Tag', - 'enable_serializer_groups' => true, + 'enable_serializer_groups' => false, 'index_if' => null, 'settings' => [], ], ], + 'nbResults' => 20, + 'batchSize' => 500, + 'serializer' => 'serializer', + 'doctrineSubscribedEvents' => ['postPersist', 'postUpdate', 'preRemove'], + ], + ], + 'expectedConfig' => [ + 'url' => 'http://localhost:7700', + 'prefix' => 'sf_', + 'indices' => [ + [ + 'name' => 'items', + 'class' => 'App\Entity\Post', + 'enable_serializer_groups' => false, + 'serializer_groups' => ['searchable'], + 'index_if' => null, 'settings' => [], + ], + [ + 'name' => 'items', + 'class' => 'App\Entity\Tag', + 'enable_serializer_groups' => false, + 'serializer_groups' => ['searchable'], + 'index_if' => null, + 'settings' => [], + ], ], + 'nbResults' => 20, + 'batchSize' => 500, + 'serializer' => 'serializer', + 'doctrineSubscribedEvents' => ['postPersist', 'postUpdate', 'preRemove'], + 'http_client' => 'psr18.http_client', ], - 'same index for multiple models' => [ - [ + ]; + + yield 'custom serializer groups' => [ + 'inputConfig' => [ + 'meilisearch' => [ 'prefix' => 'sf_', 'indices' => [ [ - 'name' => 'items', 'class' => 'App\Entity\Post', 'enable_serializer_groups' => false, 'index_if' => null, 'settings' => [], + 'name' => 'items', + 'class' => 'App\Entity\Post', + 'enable_serializer_groups' => true, + 'serializer_groups' => ['post.public', 'post.private'], + 'index_if' => null, + 'settings' => [], ], + ], + ], + ], + 'expectedConfig' => [ + 'url' => 'http://localhost:7700', + 'prefix' => 'sf_', + 'indices' => [ + [ + 'name' => 'items', + 'class' => 'App\Entity\Post', + 'enable_serializer_groups' => true, + 'serializer_groups' => ['post.public', 'post.private'], + 'index_if' => null, 'settings' => [], + ], + ], + 'nbResults' => 20, + 'batchSize' => 500, + 'serializer' => 'serializer', + 'doctrineSubscribedEvents' => ['postPersist', 'postUpdate', 'preRemove'], + 'http_client' => 'psr18.http_client', + ], + ]; + + yield 'distinct attribute' => [ + 'inputConfig' => [ + 'meilisearch' => [ + 'prefix' => 'sf_', + 'indices' => [ [ - 'name' => 'items', 'class' => 'App\Entity\Tag', 'enable_serializer_groups' => false, 'index_if' => null, 'settings' => [], + 'name' => 'items', + 'class' => 'App\Entity\Post', + 'settings' => [ + 'distinctAttribute' => 'product_id', + ], ], ], - 'nbResults' => 20, - 'batchSize' => 500, - 'serializer' => 'serializer', - 'doctrineSubscribedEvents' => ['postPersist', 'postUpdate', 'preRemove'], ], - [ + ], + 'expectedConfig' => [ + 'url' => 'http://localhost:7700', + 'prefix' => 'sf_', + 'indices' => [ + [ + 'name' => 'items', + 'class' => 'App\Entity\Post', + 'enable_serializer_groups' => false, + 'serializer_groups' => ['searchable'], + 'index_if' => null, + 'settings' => [ + 'distinctAttribute' => ['product_id'], + ], + ], + ], + 'nbResults' => 20, + 'batchSize' => 500, + 'serializer' => 'serializer', + 'doctrineSubscribedEvents' => ['postPersist', 'postUpdate', 'preRemove'], + 'http_client' => 'psr18.http_client', + ], + ]; + + yield 'proximity precision' => [ + 'inputConfig' => [ + 'meilisearch' => [ 'prefix' => 'sf_', 'indices' => [ [ - 'name' => 'items', 'class' => 'App\Entity\Post', 'enable_serializer_groups' => false, 'index_if' => null, 'settings' => [], + 'name' => 'items', + 'class' => 'App\Entity\Post', + 'settings' => [ + 'proximityPrecision' => 'byWord', + ], ], - [ - 'name' => 'items', 'class' => 'App\Entity\Tag', 'enable_serializer_groups' => false, 'index_if' => null, 'settings' => [], + ], + ], + ], + 'expectedConfig' => [ + 'url' => 'http://localhost:7700', + 'prefix' => 'sf_', + 'indices' => [ + [ + 'name' => 'items', + 'class' => 'App\Entity\Post', + 'enable_serializer_groups' => false, + 'serializer_groups' => ['searchable'], + 'index_if' => null, + 'settings' => [ + 'proximityPrecision' => ['byWord'], ], ], - 'nbResults' => 20, - 'batchSize' => 500, - 'serializer' => 'serializer', - 'doctrineSubscribedEvents' => ['postPersist', 'postUpdate', 'preRemove'], + ], + 'nbResults' => 20, + 'batchSize' => 500, + 'serializer' => 'serializer', + 'doctrineSubscribedEvents' => ['postPersist', 'postUpdate', 'preRemove'], + 'http_client' => 'psr18.http_client', + ], + ]; + + yield 'custom http client' => [ + 'inputConfig' => [ + 'meilisearch' => [ + 'http_client' => 'acme.http_client', ], ], + 'expectedConfig' => [ + 'url' => 'http://localhost:7700', + 'prefix' => null, + 'indices' => [], + 'nbResults' => 20, + 'batchSize' => 500, + 'serializer' => 'serializer', + 'doctrineSubscribedEvents' => ['postPersist', 'postUpdate', 'preRemove'], + 'http_client' => 'acme.http_client', + ], ]; } + + /** + * @return iterable + */ + public static function dataTestSettingsDynamicCheckerInvalid(): iterable + { + yield 'string is not acceptable' => [ + 'value' => 'hello', + ]; + yield 'int is not acceptable' => [ + 'value' => 1, + ]; + yield 'bool is not acceptable' => [ + 'value' => true, + ]; + } + + /** + * @return iterable + */ + public static function dataTestSettingsDynamicCheckerValid(): iterable + { + yield 'array is acceptable' => [ + 'value' => [], + ]; + yield 'array with arbitrary key is acceptable' => [ + 'value' => [ + 'key' => 'value', + 'key2' => 'value2', + ], + ]; + yield 'null is acceptable' => [ + 'value' => null, + ]; + } + + protected function getConfiguration(): Configuration + { + return new Configuration(); + } } diff --git a/tests/Unit/SerializationTest.php b/tests/Unit/SerializationTest.php index 8c987d46..631542b7 100644 --- a/tests/Unit/SerializationTest.php +++ b/tests/Unit/SerializationTest.php @@ -6,61 +6,44 @@ use Meilisearch\Bundle\Searchable; use Meilisearch\Bundle\SearchableEntity; +use Meilisearch\Bundle\Tests\BaseKernelTestCase; use Meilisearch\Bundle\Tests\Entity\Comment; use Meilisearch\Bundle\Tests\Entity\Post; -use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; -/** - * Class SerializationTest. - */ -class SerializationTest extends KernelTestCase +final class SerializationTest extends BaseKernelTestCase { public function testSimpleEntityToSearchableArray(): void { - $datetime = new \DateTime(); - $dateNormalizer = static::getContainer()->get('serializer.normalizer.datetime'); - // This way we can test that DateTime's are serialized with DateTimeNormalizer - // And not the default ObjectNormalizer - $serializedDateTime = $dateNormalizer->normalize($datetime, Searchable::NORMALIZATION_FORMAT); - - $post = new Post( - [ - 'id' => 12, - 'title' => 'a simple post', - 'content' => 'some text', - 'publishedAt' => $datetime, - ] - ); - - $comment = new Comment(); - $comment->setContent('a great comment'); - $comment->setPost($post); + $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); - $postMeta = static::getContainer()->get('doctrine')->getManager()->getClassMetadata(Post::class); - $searchablePost = new SearchableEntity( 'posts', $post, - $postMeta, - static::getContainer()->get('serializer'), - ['useSerializerGroup' => true] + self::getContainer()->get('doctrine')->getManager()->getClassMetadata(Post::class), + self::getContainer()->get('serializer'), + ['normalizationGroups' => [Searchable::NORMALIZATION_GROUP]] ); - $expected = [ + $this->assertSame([ 'id' => 12, 'title' => 'a simple post', 'content' => 'some text', - 'publishedAt' => $serializedDateTime, + 'publishedAt' => 1728994403, 'comments' => [ [ 'id' => null, 'content' => 'a great comment', - 'publishedAt' => $serializedDateTime, + 'publishedAt' => 1728994403, ], ], - ]; - - $this->assertEquals($expected, $searchablePost->getSearchableArray()); + ], $searchablePost->getSearchableArray()); } } diff --git a/tests/baseline-ignore b/tests/baseline-ignore new file mode 100644 index 00000000..bb59e40e --- /dev/null +++ b/tests/baseline-ignore @@ -0,0 +1,10 @@ +%Method "ArrayAccess::offsetGet\(\)" might add "mixed" as a native return type declaration in the future. Do the same in implementation "Meilisearch\\Contracts\\Data" now to avoid errors or add an explicit @return annotation to suppress this message.% +%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.% +%Calling Doctrine\\ORM\\Configuration::setProxyDir is deprecated and will not be possible in Doctrine ORM 4.0% +%Calling Doctrine\\ORM\\Configuration::getProxyDir is deprecated and will not be possible in Doctrine ORM 4.0% +%Calling Doctrine\\ORM\\Configuration::setAutoGenerateProxyClasses is deprecated and will not be possible in Doctrine ORM 4.0% +%Calling Doctrine\\ORM\\Configuration::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% +%Since doctrine/doctrine-bundle 2.16: Not setting "doctrine.orm.enable_native_lazy_objects" to true is deprecated% diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 00000000..43fdab44 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,9 @@ +remove(dirname(__DIR__).'/var/cache/test'); diff --git a/tests/config/config.yaml b/tests/config/config.yaml index 8d804b27..5302d56e 100644 --- a/tests/config/config.yaml +++ b/tests/config/config.yaml @@ -1,9 +1,7 @@ framework: test: true secret: 67d829bf61dc5f87a73fd814e2c9f629 - annotations: true - serializer: - enable_annotations: true + http_method_override: false doctrine: dbal: @@ -12,14 +10,18 @@ doctrine: 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 + enable_native_lazy_objects: true + auto_generate_proxy_classes: false + report_fields_where_declared: true + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware auto_mapping: true mappings: App: is_bundle: false - type: annotation + type: attribute dir: '%kernel.project_dir%/tests/Entity' prefix: 'Meilisearch\Bundle\Tests\Entity' alias: App diff --git a/tests/config/config_old_proxy.yaml b/tests/config/config_old_proxy.yaml new file mode 100644 index 00000000..87de9ee1 --- /dev/null +++ b/tests/config/config_old_proxy.yaml @@ -0,0 +1,26 @@ +framework: + test: true + secret: 67d829bf61dc5f87a73fd814e2c9f629 + http_method_override: false + +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 + report_fields_where_declared: true + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + auto_mapping: true + mappings: + App: + is_bundle: false + type: attribute + dir: '%kernel.project_dir%/tests/Entity' + prefix: 'Meilisearch\Bundle\Tests\Entity' + alias: App diff --git a/tests/config/config_php7.yaml b/tests/config/config_php7.yaml new file mode 100644 index 00000000..7f690a23 --- /dev/null +++ b/tests/config/config_php7.yaml @@ -0,0 +1,30 @@ +framework: + test: true + secret: 67d829bf61dc5f87a73fd814e2c9f629 + http_method_override: false + annotations: true + serializer: + enable_annotations: true + router: + utf8: true + +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/meilisearch.yaml b/tests/config/meilisearch.yaml index f4831129..50a06b90 100644 --- a/tests/config/meilisearch.yaml +++ b/tests/config/meilisearch.yaml @@ -1,38 +1,60 @@ meilisearch: - url: '%env(MEILISEARCH_URL)%' - api_key: '%env(MEILISEARCH_API_KEY)%' - prefix: '%env(MEILISEARCH_PREFIX)%_' - nbResults: 12 - batchSize: 100 - indices: - - name: posts - class: 'Meilisearch\Bundle\Tests\Entity\Post' - enable_serializer_groups: true - settings: - stopWords: ['the', 'a', 'an'] - filterableAttributes: ['title', 'publishedAt'] - typoTolerance: - enabled: true - disableOnAttributes: ['title'] - disableOnWords: ['york'] - minWordSizeForTypos: - oneTypo: 5 - twoTypos: 9 - - name: comments - class: 'Meilisearch\Bundle\Tests\Entity\Comment' - - name: aggregated - class: 'Meilisearch\Bundle\Tests\Entity\ContentAggregator' - index_if: isVisible - - name: tags - class: 'Meilisearch\Bundle\Tests\Entity\Tag' - index_if: isPublic - # Yes, we want to have links in the same index as tags - # We just set the same index name 'tags' - - name: tags - class: 'Meilisearch\Bundle\Tests\Entity\Link' - index_if: isSponsored - - name: pages - class: 'Meilisearch\Bundle\Tests\Entity\Page' - enable_serializer_groups: true - - name: self_normalizable - class: 'Meilisearch\Bundle\Tests\Entity\SelfNormalizable' + url: '%env(MEILISEARCH_URL)%' + api_key: '%env(MEILISEARCH_API_KEY)%' + prefix: '%env(MEILISEARCH_PREFIX)%_' + nbResults: 12 + batchSize: 100 + indices: + - name: posts + class: 'Meilisearch\Bundle\Tests\Entity\Post' + enable_serializer_groups: true + settings: + stopWords: ['the', 'a', 'an'] + filterableAttributes: ['title', 'publishedAt'] + searchCutoffMs: 1500 + typoTolerance: + enabled: true + disableOnAttributes: ['title'] + disableOnWords: ['york'] + minWordSizeForTypos: + oneTypo: 5 + twoTypos: 9 + - name: comments + class: 'Meilisearch\Bundle\Tests\Entity\Comment' + - name: aggregated + class: 'Meilisearch\Bundle\Tests\Entity\ContentAggregator' + index_if: isVisible + - name: tags + class: 'Meilisearch\Bundle\Tests\Entity\Tag' + index_if: isPublic + # Yes, we want to have links in the same index as tags + # We just set the same index name 'tags' + - name: tags + class: 'Meilisearch\Bundle\Tests\Entity\Link' + index_if: isSponsored + - name: discriminator_map + class: 'Meilisearch\Bundle\Tests\Entity\ContentItem' + - name: pages + class: 'Meilisearch\Bundle\Tests\Entity\Page' + enable_serializer_groups: true + - name: self_normalizable + class: 'Meilisearch\Bundle\Tests\Entity\SelfNormalizable' + - name: dummy_custom_groups + class: 'Meilisearch\Bundle\Tests\Entity\DummyCustomGroups' + enable_serializer_groups: true + serializer_groups: ['public', 'private'] + - name: dynamic_settings + class: 'Meilisearch\Bundle\Tests\Entity\DynamicSettings' + settings: + filterableAttributes: + _service: '@Meilisearch\Bundle\Tests\Integration\Fixtures\FilterableAttributes' + searchableAttributes: + _service: '@Meilisearch\Bundle\Tests\Integration\Fixtures\SearchableAttributes' + stopWords: + _service: '@Meilisearch\Bundle\Tests\Integration\Fixtures\StopWords' + synonyms: + _service: '@Meilisearch\Bundle\Tests\Integration\Fixtures\Synonyms' + +services: + Meilisearch\Bundle\Tests\Integration\Fixtures\: + resource: '../Integration/Fixtures/'