├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .php-cs-fixer.php ├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── doc └── cookbook │ └── autocomplete.md ├── phpunit.xml.dist ├── src ├── ACSEOTypesenseBundle.php ├── Client │ ├── CollectionClient.php │ └── TypesenseClient.php ├── Command │ ├── CreateCommand.php │ └── ImportCommand.php ├── Controller │ └── TypesenseAutocompleteController.php ├── DependencyInjection │ ├── ACSEOTypesenseExtension.php │ └── Configuration.php ├── EventListener │ └── TypesenseIndexer.php ├── Exception │ └── TypesenseException.php ├── Finder │ ├── CollectionFinder.php │ ├── CollectionFinderInterface.php │ ├── SpecificCollectionFinder.php │ ├── SpecificCollectionFinderInterface.php │ ├── TypesenseQuery.php │ └── TypesenseResponse.php ├── Manager │ ├── CollectionManager.php │ └── DocumentManager.php ├── Resources │ └── config │ │ ├── commands.xml │ │ └── services.xml └── Transformer │ ├── AbstractTransformer.php │ └── DoctrineToTypesenseTransformer.php └── tests ├── Functional ├── AllowNullConnexionTest.php ├── Entity │ ├── Author.php │ ├── Book.php │ └── BookOnline.php ├── Service │ ├── BookConverter.php │ └── ExceptionBookConverter.php └── TypesenseInteractionsTest.php ├── Hook └── BypassFinalHook.php └── Unit ├── DependencyInjection ├── ACSEOTypesenseExtensionTest.php └── fixtures │ ├── acseo_typesense.yml │ └── acseo_typesense_collection_prefix.yml ├── EventListener └── TypesenseIndexerTest.php ├── Finder ├── TypesenseQueryTest.php └── TypesenseResponseTest.php └── Transformer └── DoctrineToTypesenseTransformerTest.php /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | phpunit.xml 2 | vendor 3 | composer.lock 4 | composer.phar 5 | .php_cs 6 | .php_cs.cache 7 | .phpunit.result.cache -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | setUsingCache(true) 5 | ->setRiskyAllowed(true) 6 | ->setRules([ 7 | '@DoctrineAnnotation' => true, 8 | '@Symfony' => true, 9 | '@Symfony:risky' => true, 10 | '@PHP71Migration' => true, 11 | '@PHP71Migration:risky' => true, 12 | 13 | 'align_multiline_comment' => [ 14 | 'comment_type' => 'all_multiline', 15 | ], 16 | 'native_function_invocation' => false, 17 | 'no_multiline_whitespace_around_double_arrow' => true, 18 | 'array_indentation' => true, 19 | 'array_syntax' => [ 20 | 'syntax' => 'short', 21 | ], 22 | 'backtick_to_shell_exec' => true, 23 | 'blank_line_before_statement' => true, 24 | 'combine_consecutive_issets' => true, 25 | 'combine_consecutive_unsets' => true, 26 | 'compact_nullable_typehint' => true, 27 | 'escape_implicit_backslashes' => true, 28 | 'explicit_indirect_variable' => true, 29 | 'explicit_string_variable' => true, 30 | 'fully_qualified_strict_types' => true, 31 | 'function_to_constant' => [ 32 | 'functions' => ['get_called_class', 'get_class', 'php_sapi_name', 'phpversion', 'pi'], 33 | ], 34 | 'single_line_comment_style' => ['hash'], 35 | 'header_comment' => [ 36 | 'header' => '', 37 | ], 38 | 'heredoc_to_nowdoc' => true, 39 | 'linebreak_after_opening_tag' => true, 40 | 'logical_operators' => true, 41 | 'method_chaining_indentation' => true, 42 | 'multiline_comment_opening_closing' => true, 43 | 'multiline_whitespace_before_semicolons' => [ 44 | 'strategy' => 'new_line_for_chained_calls', 45 | ], 46 | 'native_constant_invocation' => false, 47 | 'no_binary_string' => true, 48 | 'no_null_property_initialization' => true, 49 | 'no_php4_constructor' => true, 50 | 'no_superfluous_elseif' => true, 51 | 'no_superfluous_phpdoc_tags' => true, 52 | 'no_unset_on_property' => true, 53 | 'no_useless_else' => true, 54 | 'no_useless_return' => true, 55 | 'non_printable_character' => [ 56 | 'use_escape_sequences_in_strings' => true, 57 | ], 58 | 'ordered_imports' => true, 59 | 'phpdoc_order' => true, 60 | 'phpdoc_to_return_type' => true, 61 | 'phpdoc_trim_consecutive_blank_line_separation' => true, 62 | 'phpdoc_types_order' => [ 63 | 'sort_algorithm' => 'none', 64 | 'null_adjustment' => 'always_last', 65 | ], 66 | 'php_unit_set_up_tear_down_visibility' => true, 67 | 'php_unit_test_annotation' => true, 68 | 'php_unit_test_case_static_method_calls' => [ 69 | 'call_type' => 'self', 70 | ], 71 | 'pow_to_exponentiation' => true, 72 | 'psr_autoloading' => true, 73 | 'random_api_migration' => true, 74 | 'return_assignment' => true, 75 | 'error_suppression' => true, 76 | 'single_line_comment_style' => true, 77 | 'strict_comparison' => true, 78 | 'strict_param' => true, 79 | 'string_line_ending' => true, 80 | 'ternary_to_null_coalescing' => true, 81 | 'void_return' => false, 82 | 'yoda_style' => [ 83 | 'always_move_variable' => false, 84 | 'equal' => false, 85 | 'identical' => false, 86 | 'less_and_greater' => false 87 | ], 88 | 'binary_operator_spaces' => [ 89 | 'default' => 'align_single_space_minimal' 90 | ], 91 | 'phpdoc_align' => [ 92 | 'align' => 'vertical' 93 | ] 94 | ]) 95 | ->setFinder( 96 | (new PhpCsFixer\Finder()) 97 | ->notName('Kernel.php') 98 | ->notName('bootstrap.php') 99 | ->in([ 100 | __DIR__.'/src', 101 | __DIR__.'/tests', 102 | ]) 103 | ) 104 | ; 105 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | sudo: false 3 | cache: 4 | directories: 5 | - $HOME/.composer/cache/files 6 | env: 7 | global: 8 | - PHPUNIT_FLAGS="-v" 9 | matrix: 10 | fast_finish: true 11 | include: 12 | # Minimum supported dependencies with the latest and oldest PHP version 13 | - php: 7.4 14 | - php: 8.0 15 | before_install: 16 | - if [[ $COVERAGE != true ]]; then phpenv config-rm xdebug.ini || true; fi 17 | - if ! [ -z "$STABILITY" ]; then composer config minimum-stability ${STABILITY}; fi; 18 | - if ! [ -v "$DEPENDENCIES" ]; then composer require --no-update ${DEPENDENCIES}; fi; 19 | install: 20 | - composer update ${COMPOSER_FLAGS} --prefer-dist --no-interaction 21 | script: 22 | - composer validate --strict --no-check-lock 23 | # simple-phpunit is the PHPUnit wrapper provided by the PHPUnit Bridge component and 24 | # it helps with testing legacy code and deprecations (composer require symfony/phpunit-bridge) 25 | - ./vendor/bin/phpunit $PHPUNIT_FLAGS tests/Unit -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 ACSEO 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ACSEOTypesenseBundle 2 | 3 | This bundle provides integration with [Typesense](https://typesense.org/) with Symfony. 4 | 5 | It relies on the official [TypeSense PHP](https://github.com/typesense/typesense-php) package 6 | 7 | Features include: 8 | 9 | - Doctrine object transformer to Typesense indexable data 10 | - Usefull services to search in collections 11 | - Listeners for Doctrine events for automatic indexing 12 | 13 | ## Installation 14 | 15 | Install the bundle using composer 16 | 17 | ```bash 18 | composer require acseo/typesense-bundle 19 | ```` 20 | 21 | Enable the bundle in you Symfony project 22 | 23 | ```php 24 | 25 | ['all' => true], 30 | ``` 31 | 32 | ## Configuration 33 | 34 | Configure the Bundle 35 | 36 | ``` 37 | # .env 38 | TYPESENSE_URL=http://localhost:8108 39 | TYPESENSE_KEY=123 40 | ``` 41 | 42 | ```yaml 43 | # config/packages/acseo_typesense.yml 44 | acseo_typesense: 45 | # Typesense host settings 46 | typesense: 47 | url: '%env(resolve:TYPESENSE_URL)%' 48 | key: '%env(resolve:TYPESENSE_KEY)%' 49 | collection_prefix: 'test_' # Optional : add prefix to all collection 50 | # names in Typesense 51 | # Collection settings 52 | collections: 53 | books: # Typesense collection name 54 | entity: 'App\Entity\Book' # Doctrine Entity class 55 | fields: 56 | # 57 | # Keeping Database and Typesense synchronized with ids 58 | # 59 | id: # Entity attribute name 60 | name: id # Typesense attribute name 61 | type: primary # Attribute type 62 | # 63 | # Using again id as a sortable field (int32 required) 64 | # 65 | sortable_id: 66 | entity_attribute: id # Entity attribute name forced 67 | name: sortable_id # Typesense field name 68 | type: int32 69 | title: 70 | name: title 71 | type: string 72 | description: 73 | name: title 74 | type: description 75 | author: 76 | name: author 77 | type: object # Object conversion with __toString() 78 | author.country: 79 | name: author_country 80 | type: string 81 | facet: true # Declare field as facet (required to use "group_by" query option) 82 | entity_attribute: author.country # Equivalent of $book->getAuthor()->getCountry() 83 | genres: 84 | name: genres 85 | type: collection # Convert ArrayCollection to array of strings 86 | publishedAt: 87 | name: publishedAt 88 | type: datetime 89 | optional: true # Declare field as optional 90 | cover_image_url: 91 | name: cover_image_url 92 | type: string 93 | optional: true 94 | entity_attribute: ACSEO\Service\BookConverter::getCoverImageURL # use a service converter instead of an attribute 95 | embeddings: # Since Typesense 0.25, you can generate Embeddings on the fly 96 | name: embeddings # and retrieve your documents using an vectorial search 97 | type: float[] # more info : https://typesense.org/docs/27.0/api/vector-search.html 98 | embed: 99 | from: 100 | - title 101 | - description 102 | model_config: 103 | model_name: ts/e5-small 104 | default_sorting_field: sortable_id # Default sorting field. Must be int32 or float 105 | symbols_to_index: ['+'] # Optional - You can add + to this list to make the word c++ indexable verbatim. 106 | users: 107 | entity: App\Entity\User 108 | fields: 109 | id: 110 | name: id 111 | type: primary 112 | sortable_id: 113 | entity_attribute: id 114 | name: sortable_id 115 | type: int32 116 | email: 117 | name: email 118 | type: string 119 | default_sorting_field: sortable_id 120 | token_separators: ['+', '-', '@', '.'] # Optional - This will cause contact+docs-example@typesense.org to be indexed as contact, docs, example, typesense and org. 121 | ``` 122 | 123 | You can use basic types supported by Typesense for your fields : string, int32, float, etc. 124 | You can also use specific type names, such as : primary, collection, object 125 | 126 | Data conversion from Doctrine entity to Typesense data is managed by `ACSEO\TypesenseBundle\Transformer\DoctrineToTypesenseTransformer` 127 | 128 | ## Usage 129 | 130 | ### Create index and populate data 131 | 132 | This bundle comes with useful commands in order to create and index your data 133 | 134 | ```yaml 135 | # Creation collections structure 136 | php bin/console typesense:create 137 | 138 | # Import collections with Doctrine entities 139 | php bin/console typesense:import 140 | ``` 141 | 142 | ### Search documents 143 | 144 | This bundle creates dynamic generic **finders** services that allows you to query Typesense 145 | 146 | The finder services are named like this : typesense.finder.*collection_name* 147 | 148 | You can inject the generic finder in your Controller or into other services. 149 | 150 | You can also create specific finder for a collection. See documentation below. 151 | 152 | ```yaml 153 | # config/services.yaml 154 | services: 155 | App\Controller\BookController: 156 | arguments: 157 | $bookFinder: '@typesense.finder.books' 158 | ``` 159 | 160 | ```php 161 | bookFinder = $bookFinder; 176 | } 177 | 178 | public function search() 179 | { 180 | $query = new TypesenseQuery('Jules Vernes', 'author'); 181 | 182 | // Get Doctrine Hydrated objects 183 | $results = $this->bookFinder->query($query)->getResults(); 184 | 185 | // dump($results) 186 | // array:2 [▼ 187 | // 0 => App\Entity\Book {#522 ▶} 188 | // 1 => App\Entity\Book {#525 ▶} 189 | //] 190 | 191 | // Get raw results from Typesence 192 | $rawResults = $this->bookFinder->rawQuery($query)->getResults(); 193 | 194 | // dump($rawResults) 195 | // array:2 [▼ 196 | // 0 => array:3 [▼ 197 | // "document" => array:4 [▼ 198 | // "author" => "Jules Vernes" 199 | // "id" => "100" 200 | // "published_at" => 1443744000 201 | // "title" => "Voyage au centre de la Terre " 202 | // ] 203 | // "highlights" => array:1 [▶] 204 | // "seq_id" => 4 205 | // ] 206 | // 1 => array:3 [▼ 207 | // "document" => array:4 [▶] 208 | // "highlights" => array:1 [▶] 209 | // "seq_id" => 6 210 | // ] 211 | // ] 212 | } 213 | ``` 214 | 215 | ### Querying Typesense 216 | 217 | The class `TypesenseQuery()` class takes 2 arguments : 218 | 219 | * The search terme (`q`) 220 | * The fields to search on (`queryBy`) 221 | 222 | You can create more complex queries using all the possible Typsense [search arguments](https://typesense.org/docs/0.21.0/api/documents.html#arguments) 223 | 224 | ```php 225 | filterBy('theme: [adventure, thriller]') 233 | ->addParameter('key', 'value') 234 | ->sortBy('year:desc'); 235 | ``` 236 | 237 | ### Create specific finder for a collection 238 | 239 | You can easily create specific finders for each collection that you declare. 240 | 241 | ```yaml 242 | # config/packages/acseo_typesense.yml 243 | acseo_typesense: 244 | # ... 245 | # Collection settings 246 | collections: 247 | books: # Typesense collection name 248 | # ... # Colleciton fields definition 249 | # ... 250 | finders: # Declare your specific finder 251 | books_autocomplete: # Finder name 252 | finder_parameters: # Parameters used by the finder 253 | query_by: title # 254 | limit: 10 # You can add as key / valuesspecifications 255 | prefix: true # based on Typesense Request 256 | num_typos: 1 # 257 | drop_tokens_threshold: 1 # 258 | ``` 259 | 260 | This configuration will create a service named `@typesense.finder.books.books_autocomplete`. 261 | You can inject the specific finder in your Controller or into other services 262 | 263 | ```yaml 264 | # config/services.yaml 265 | services: 266 | App\Controller\BookController: 267 | arguments: 268 | $autocompleteBookFinder: '@typesense.finder.books.books_autocomplete' 269 | ``` 270 | 271 | and then use it like this : 272 | 273 | ```php 274 | autocompleteBookFinder = $autocompleteBookFinder; 284 | } 285 | 286 | public function autocomplete($term = '') 287 | { 288 | $results = $this->autocompleteBookFinder->search($term)->getResults(); 289 | // or if you want raw results 290 | $rawResults = $this->autocompleteBookFinder->search($term)->getRawResults(); 291 | } 292 | ``` 293 | 294 | ### Use different kind of services 295 | 296 | This bundles creates different services that you can use in your Controllers or anywhere you want. 297 | 298 | * `typesense.client` : the basic client inherited from the official `typesense-php` package 299 | * `typesense.collection_client` : this service allows you to do basic actions on collections, and allows to perform `search` and `multisearch` action. 300 | * `typesense.finder.*` : this generated service allows you to perform `query` or `rawQuery` on a specific collection. Example of a generated service : `typesense.finder.candidates` 301 | * `typesense.specificfinder.*.*` : this generated service allows you to run pre-configured requests (declared in : `config/packages/acseo_typesense.yml`). Example of a generated service : `typesense.specificfinder.candidates.default` 302 | 303 | Note : there a other services. You can use the `debug:container` command in order to see all of them. 304 | 305 | ### Doctrine Listeners 306 | 307 | Doctrine listeners will update Typesense with Entity data during the following events : 308 | 309 | * postPersist 310 | * postUpdate 311 | * preDelete 312 | 313 | ### Perform multisearch 314 | 315 | You can create [multisearch](https://typesense.org/docs/0.21.0/api/documents.html#federated-multi-search) requests and get results using the `collectionClient` service. 316 | 317 | ```php 318 | // Peform multisearch 319 | 320 | $searchRequests = [ 321 | (new TypesenseQuery('Jules'))->addParameter('collection', 'author'), 322 | (new TypesenseQuery('Paris'))->addParameter('collection', 'library') 323 | ]; 324 | 325 | $commonParams = new TypesenseQuery()->addParameter('query_by', 'name'); 326 | 327 | $response = $this->collectionClient->multisearch($searchRequests, $commonParams); 328 | ``` 329 | 330 | ## Cookbook 331 | ---------------- 332 | 333 | * [Use Typesense to make an autocomplete field](doc/cookbook/autocomplete.md) 334 | 335 | 336 | ## Testing the Bundle 337 | 338 | tests are written in the `tests` directory. 339 | 340 | * **Unit** tests doesn't require a running Typesense server 341 | * **Functional** tests require a running Typesense server 342 | 343 | You can launch the tests with the following commands : 344 | 345 | ```bash 346 | # Unit test 347 | $ php ./vendor/bin/phpunit tests/Unit 348 | 349 | # Functional test 350 | # First, start a Typesense server with Docker 351 | $ composer run-script typesenseServer 352 | $ php ./vendor/bin/phpunit tests/Functional 353 | ``` 354 | 355 | 356 | 357 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "acseo/typesense-bundle", 3 | "description": "This bundle provides integration with Typesense in Symfony", 4 | "type": "symfony-bundle", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Nicolas Potier", 9 | "email": "nicolas.potier@acseo.fr" 10 | } 11 | ], 12 | "require": { 13 | "php": "^7.4||^8.0", 14 | "doctrine/orm": "^2.8 || ^3.2", 15 | "symfony/framework-bundle": "^4.3|^5|^6.0|^7.0", 16 | "symfony/console": "^4.3.4|^5|^6.0|^7.0", 17 | "typesense/typesense-php": "^4.1.0", 18 | "php-http/curl-client": "^2.2", 19 | "monolog/monolog": "^2.3|^3.0", 20 | "symfony/property-access": "^3.4|^4.3|^5|^6.0|^7.0", 21 | "symfony/http-client": "^5.4|^6.2|^7.0" 22 | }, 23 | "require-dev": { 24 | "symfony/phpunit-bridge": "^5.0|^6.0", 25 | "phpunit/phpunit": "^9.5", 26 | "symfony/yaml": "^3.4 || ^4.4 || ^5.4 || ^6.0", 27 | "dg/bypass-finals": "^1.4", 28 | "phpspec/prophecy-phpunit": "^2.0" 29 | }, 30 | "autoload": { 31 | "psr-4": { "ACSEO\\TypesenseBundle\\": "src/" } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "ACSEO\\TypesenseBundle\\Tests\\": "tests/" 36 | } 37 | }, 38 | "scripts": { 39 | "typesenseServer": [ 40 | "Composer\\Config::disableProcessTimeout", 41 | "docker run -i -p 8108:8108 -v/tmp/typesense-server-data-1c/:/data typesense/typesense:27.1 --data-dir /data --api-key=123 --listen-port 8108 --enable-cors" 42 | ] 43 | }, 44 | "config": { 45 | "allow-plugins": { 46 | "php-http/discovery": true 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /doc/cookbook/autocomplete.md: -------------------------------------------------------------------------------- 1 | # Cookbook : Autocomplete 2 | 3 | With Typsense and some Javascript, it can be pretty easy to create an autocomplete field based on an ajax request. 4 | 5 | In order to do that, TypesenseBundle prodive a generic autocomplete controller that will allow to use a finder in order 6 | to search for data, and return a JSON Response that could be used to populate the autocomplete field 7 | 8 | ## Step 1 : declare a specific finder 9 | 10 | First of all, we will need a finder in order to search for data. A specific finder can easily be declared. 11 | 12 | ```yaml 13 | # config/packages/acseo_typesense.yml 14 | acseo_typesense: 15 | # ... 16 | # Collection settings 17 | collections: 18 | books: # Typesense collection name 19 | # ... # Colleciton fields definition 20 | # ... 21 | finders: # Declare your specific finder 22 | books_autocomplete: # Finder name 23 | finder_parameters: # Parameters used by the finder 24 | query_by: title # 25 | limit: 10 # You can add as key / valuesspecifications 26 | prefix: true # based on Typesense Request 27 | num_typos: 1 # 28 | drop_tokens_threshold: 1 # 29 | ``` 30 | 31 | ## Step 2 : enable the autocomplete route 32 | 33 | ```yaml 34 | # config/routes.yaml 35 | autocomplete: 36 | path: /autocomplete 37 | controller: typesense.autocomplete_controller:autocomplete 38 | ``` 39 | 40 | ## Step 3 : Create an ajax request that will populate your search field. 41 | 42 | The example bellow is based on [bootstrap-autocomplete](https://github.com/xcash/bootstrap-autocomplete) but you can use any script you want once you understand how this works 43 | 44 | ```html 45 | 46 | 47 | 48 | 49 | 50 | 51 |
52 | {% block body %}{% endblock %} 53 |
54 | 55 | 56 | 57 | {% block javascripts %}{% endblock %} 58 | 59 | 60 | ``` 61 | 62 | ```html 63 | 64 | {% extends 'base.html.twig' %} 65 | 66 | {% block body %} 67 | 68 | {% endblock %} 69 | 70 | {% block javascripts %} 71 | 72 | 94 | {% endblock %} 95 | ``` -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./src 6 | 7 | 8 | ./src/Resources 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ./tests 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/ACSEOTypesenseBundle.php: -------------------------------------------------------------------------------- 1 | client = $client; 16 | } 17 | 18 | public function search(string $collectionName, TypesenseQuery $query) 19 | { 20 | if (!$this->client->isOperationnal()) { 21 | return null; 22 | } 23 | 24 | return $this->client->collections[$collectionName]->documents->search($query->getParameters()); 25 | } 26 | 27 | public function multiSearch(array $searchRequests, ?TypesenseQuery $commonSearchParams = null) 28 | { 29 | if (!$this->client->isOperationnal()) { 30 | return null; 31 | } 32 | 33 | $searches = []; 34 | foreach ($searchRequests as $sr) { 35 | if (!$sr instanceof TypesenseQuery) { 36 | throw new \Exception('searchRequests must be an array of TypesenseQuery objects'); 37 | } 38 | if (!$sr->hasParameter('collection')) { 39 | throw new \Exception('TypesenseQuery must have the key : `collection` in order to perform multiSearch'); 40 | } 41 | $searches[] = $sr->getParameters(); 42 | } 43 | 44 | return $this->client->multiSearch->perform( 45 | [ 46 | 'searches' => $searches, 47 | ], 48 | $commonSearchParams ? $commonSearchParams->getParameters() : [] 49 | ); 50 | } 51 | 52 | public function list() 53 | { 54 | if (!$this->client->isOperationnal()) { 55 | return null; 56 | } 57 | 58 | return $this->client->collections->retrieve(); 59 | } 60 | 61 | public function create($name, $fields, $defaultSortingField, array $tokenSeparators, array $symbolsToIndex, bool $enableNestedFields = false, array $embed = null) 62 | { 63 | if (!$this->client->isOperationnal()) { 64 | return null; 65 | } 66 | 67 | $options = [ 68 | 'name' => $name, 69 | 'fields' => $fields, 70 | 'default_sorting_field' => $defaultSortingField, 71 | 'token_separators' => $tokenSeparators, 72 | 'symbols_to_index' => $symbolsToIndex, 73 | 'enable_nested_fields' => $enableNestedFields, 74 | ]; 75 | 76 | if ($embed) { 77 | $options['embed'] = $embed; 78 | } 79 | 80 | $this->client->collections->create($options); 81 | } 82 | 83 | public function delete(string $name) 84 | { 85 | if (!$this->client->isOperationnal()) { 86 | return null; 87 | } 88 | 89 | return $this->client->collections[$name]->delete(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Client/TypesenseClient.php: -------------------------------------------------------------------------------- 1 | client = new Client([ 30 | 'nodes' => [ 31 | [ 32 | 'host' => $urlParsed['host'], 33 | 'port' => $urlParsed['port'], 34 | 'protocol' => $urlParsed['scheme'], 35 | ], 36 | ], 37 | 'api_key' => $apiKey, 38 | 'connection_timeout_seconds' => 5, 39 | ]); 40 | } 41 | 42 | public function getCollections(): ?Collections 43 | { 44 | if (!$this->client) { 45 | return null; 46 | } 47 | 48 | return $this->client->collections; 49 | } 50 | 51 | public function getAliases(): ?Aliases 52 | { 53 | if (!$this->client) { 54 | return null; 55 | } 56 | 57 | return $this->client->aliases; 58 | } 59 | 60 | public function getKeys(): ?Keys 61 | { 62 | if (!$this->client) { 63 | return null; 64 | } 65 | 66 | return $this->client->keys; 67 | } 68 | 69 | public function getDebug(): ?Debug 70 | { 71 | if (!$this->client) { 72 | return null; 73 | } 74 | 75 | return $this->client->debug; 76 | } 77 | 78 | public function getMetrics(): ?Metrics 79 | { 80 | if (!$this->client) { 81 | return null; 82 | } 83 | 84 | return $this->client->metrics; 85 | } 86 | 87 | public function getHealth(): ?Health 88 | { 89 | if (!$this->client) { 90 | return null; 91 | } 92 | 93 | return $this->client->health; 94 | } 95 | 96 | public function getOperations(): ?Operations 97 | { 98 | if (!$this->client) { 99 | return null; 100 | } 101 | 102 | return $this->client->operations; 103 | } 104 | 105 | public function getMultiSearch(): ?MultiSearch 106 | { 107 | if (!$this->client) { 108 | return null; 109 | } 110 | 111 | return $this->client->multiSearch; 112 | } 113 | 114 | /** 115 | * This allow to be use to use new Typesense\Client functions 116 | * before we update this client. 117 | */ 118 | public function __call($name, $arguments) 119 | { 120 | if (!$this->client) { 121 | return null; 122 | } 123 | 124 | return $this->client->{$name}(...$arguments); 125 | } 126 | 127 | public function __get($name) 128 | { 129 | if (!$this->client) { 130 | return null; 131 | } 132 | 133 | return $this->client->{$name}; 134 | } 135 | 136 | public function isOperationnal(): bool 137 | { 138 | return $this->client !== null; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Command/CreateCommand.php: -------------------------------------------------------------------------------- 1 | collectionManager = $collectionManager; 23 | } 24 | 25 | protected function configure() 26 | { 27 | $this 28 | ->setName(self::$defaultName) 29 | ->addOption('indexes', null, InputOption::VALUE_OPTIONAL, 'The index(es) to repopulate. Comma separated values') 30 | ->setDescription('Create Typsenses indexes') 31 | 32 | ; 33 | } 34 | 35 | protected function execute(InputInterface $input, OutputInterface $output): int 36 | { 37 | $io = new SymfonyStyle($input, $output); 38 | 39 | $collectionDefinitions = $this->collectionManager->getCollectionDefinitions(); 40 | $indexes = (null !== $indexes = $input->getOption('indexes')) ? explode(',', $indexes) : \array_keys($collectionDefinitions); 41 | 42 | foreach ($indexes as $index) { 43 | if (!isset($collectionDefinitions[$index])) { 44 | $io->error('Unable to find index "'.$index.'" in collection definition (available : '.implode(', ', array_keys($collectionDefinitions)).')'); 45 | 46 | return 2; 47 | } 48 | } 49 | 50 | // filter collection definitions 51 | $collectionDefinitions = array_filter($collectionDefinitions, function ($key) use ($indexes) { 52 | return \in_array($key, $indexes, true); 53 | }, ARRAY_FILTER_USE_KEY); 54 | 55 | foreach ($collectionDefinitions as $name => $def) { 56 | $name = $def['name']; 57 | $typesenseName = $def['typesense_name']; 58 | try { 59 | $output->writeln(sprintf('Deleting %s (%s in Typesense)', $name, $typesenseName)); 60 | $this->collectionManager->deleteCollection($name); 61 | } catch (\Typesense\Exceptions\ObjectNotFound $exception) { 62 | $output->writeln(sprintf('Collection %s does not exists ', $typesenseName)); 63 | } 64 | 65 | $output->writeln(sprintf('Creating %s (%s in Typesense)', $name, $typesenseName)); 66 | $this->collectionManager->createCollection($name); 67 | } 68 | 69 | return 0; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Command/ImportCommand.php: -------------------------------------------------------------------------------- 1 | em = $em; 40 | $this->collectionManager = $collectionManager; 41 | $this->documentManager = $documentManager; 42 | $this->transformer = $transformer; 43 | } 44 | 45 | protected function configure() 46 | { 47 | $this 48 | ->setName(self::$defaultName) 49 | ->setDescription('Import collections from Database') 50 | ->addOption('action', null, InputOption::VALUE_OPTIONAL, 'Action modes for typesense import ("create", "upsert" or "update")', 'upsert') 51 | ->addOption('indexes', null, InputOption::VALUE_OPTIONAL, 'The index(es) to repopulate. Comma separated values') 52 | ->addOption('first-page', null, InputOption::VALUE_REQUIRED, 'The pager\'s page to start population from. Including the given page.', 1) 53 | ->addOption('last-page', null, InputOption::VALUE_REQUIRED, 'The pager\'s page to end population on. Including the given page.', null) 54 | ->addOption('max-per-page', null, InputOption::VALUE_REQUIRED, 'The pager\'s page size', 100) 55 | ; 56 | } 57 | 58 | protected function execute(InputInterface $input, OutputInterface $output): int 59 | { 60 | $io = new SymfonyStyle($input, $output); 61 | 62 | if (!in_array($input->getOption('action'), self::ACTIONS, true)) { 63 | $io->error('Action option only takes the values : "create", "upsert" or "update"'); 64 | 65 | return 1; 66 | } 67 | 68 | $action = $input->getOption('action'); 69 | 70 | // 'setMiddlewares' method only exists for Doctrine version >=3.0.0 71 | if (method_exists($this->em->getConnection()->getConfiguration(), 'setMiddlewares')) { 72 | $this->em->getConnection()->getConfiguration()->setMiddlewares( 73 | [new \Doctrine\DBAL\Logging\Middleware(new \Psr\Log\NullLogger())] 74 | ); 75 | } else { 76 | // keep compatibility with versions 2.x.x of Doctrine 77 | $this->em->getConnection()->getConfiguration()->setSQLLogger(null); 78 | } 79 | 80 | $execStart = microtime(true); 81 | $populated = 0; 82 | 83 | $io->newLine(); 84 | 85 | $collectionDefinitions = $this->collectionManager->getCollectionDefinitions(); 86 | 87 | $indexes = (null !== $indexes = $input->getOption('indexes')) ? explode(',', $indexes) : \array_keys($collectionDefinitions); 88 | foreach ($indexes as $index) { 89 | if (!isset($collectionDefinitions[$index])) { 90 | $io->error('Unable to find index "'.$index.'" in collection definition (available : '.implode(', ', array_keys($collectionDefinitions)).')'); 91 | 92 | return 2; 93 | } 94 | } 95 | 96 | foreach ($indexes as $index) { 97 | try { 98 | $populated += $this->populateIndex($input, $output, $index); 99 | } catch (\Throwable $e) { 100 | $this->isError = true; 101 | $io->error($e->getMessage()); 102 | 103 | return 2; 104 | } 105 | } 106 | 107 | $io->newLine(); 108 | if (!$this->isError) { 109 | $io->success(sprintf( 110 | '%s element%s populated in %s seconds', 111 | $populated, 112 | $populated > 1 ? 's' : '', 113 | round(microtime(true) - $execStart, PHP_ROUND_HALF_DOWN) 114 | )); 115 | } 116 | 117 | return 0; 118 | } 119 | 120 | private function populateIndex(InputInterface $input, OutputInterface $output, string $index) 121 | { 122 | $populated = 0; 123 | $io = new SymfonyStyle($input, $output); 124 | 125 | $collectionDefinitions = $this->collectionManager->getCollectionDefinitions(); 126 | $collectionDefinition = $collectionDefinitions[$index]; 127 | $action = $input->getOption('action'); 128 | 129 | $firstPage = $input->getOption('first-page'); 130 | $maxPerPage = $input->getOption('max-per-page'); 131 | 132 | $collectionName = $collectionDefinition['typesense_name']; 133 | $class = $collectionDefinition['entity']; 134 | 135 | $nbEntities = (int) $this->em->createQuery('select COUNT(u.id) from '.$class.' u')->getSingleScalarResult(); 136 | 137 | $nbPages = ceil($nbEntities / $maxPerPage); 138 | 139 | if ($input->getOption('last-page')) { 140 | $lastPage = $input->getOption('last-page'); 141 | if ($lastPage > $nbPages) { 142 | throw new \Exception('The last-page option ('.$lastPage.') is bigger than the number of pages ('.$nbPages.')'); 143 | } 144 | } else { 145 | $lastPage = $nbPages; 146 | } 147 | 148 | if ($lastPage < $firstPage) { 149 | throw new \Exception('The first-page option ('.$firstPage.') is bigger than the last-page option ('.$lastPage.')'); 150 | } 151 | 152 | $io->text('['.$collectionName.'] '.$class.' '.$nbEntities.' entries to insert splited into '.$nbPages.' pages of '.$maxPerPage.' elements. Insertion from page '.$firstPage.' to '.$lastPage.'.'); 153 | 154 | for ($i = $firstPage; $i <= $lastPage; ++$i) { 155 | $q = $this->em->createQuery('select e from '.$class.' e') 156 | ->setFirstResult(($i - 1) * $maxPerPage) 157 | ->setMaxResults($maxPerPage) 158 | ; 159 | 160 | if ($io->isDebug()) { 161 | $io->text('Running request : '.$q->getSQL()); 162 | } 163 | 164 | $entities = $q->toIterable(); 165 | 166 | $data = []; 167 | foreach ($entities as $entity) { 168 | $data[] = $this->transformer->convert($entity); 169 | } 170 | 171 | $io->text('Import ['.$collectionName.'] '.$class.' Page '.$i.' of '.$lastPage.' ('.count($data).' items)'); 172 | 173 | $result = $this->documentManager->import($collectionName, $data, $action); 174 | 175 | if ($this->printErrors($io, $result)) { 176 | $this->isError = true; 177 | 178 | throw new \Exception('Error happened during the import of the collection : '.$collectionName.' (you can see them with the option -v)'); 179 | } 180 | 181 | $populated += count($data); 182 | } 183 | 184 | $io->newLine(); 185 | 186 | return $populated; 187 | } 188 | 189 | private function printErrors(SymfonyStyle $io, array $result): bool 190 | { 191 | $isError = false; 192 | foreach ($result as $item) { 193 | if (!$item['success']) { 194 | $isError = true; 195 | $io->error($item['error']); 196 | } 197 | } 198 | 199 | return $isError; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/Controller/TypesenseAutocompleteController.php: -------------------------------------------------------------------------------- 1 | routesConfig = $routesConfig; 18 | } 19 | 20 | public function autocomplete(Request $request): JsonResponse 21 | { 22 | $finderName = $request->get('finder_name', null); 23 | $q = $request->get('q', null); 24 | if (!isset($this->routesConfig[$finderName])) { 25 | throw new NotFoundHttpException('no autocomplete found with the name : '.$finderName); 26 | } 27 | 28 | $results = $this->routesConfig[$finderName]->search($q); 29 | 30 | return new JsonResponse($results->getRawResults()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/DependencyInjection/ACSEOTypesenseExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration($configuration, $configs); 42 | 43 | if (empty($config['typesense']) || empty($config['collections'])) { 44 | // No Host or collection are defined 45 | return; 46 | } 47 | 48 | $loader = new XMlFileLoader( 49 | $container, 50 | new FileLocator(__DIR__.'/../Resources/config') 51 | ); 52 | $loader->load('services.xml'); 53 | 54 | $this->loadClient($config['typesense'], $container); 55 | 56 | $this->loadCollections($config['collections'], $container); 57 | 58 | $this->loadCollectionManager($container); 59 | $this->loadCollectionsFinder($container); 60 | 61 | $this->loadFinderServices($container); 62 | 63 | $this->loadTransformer($container); 64 | $this->configureController($container); 65 | } 66 | 67 | /** 68 | * Loads the configured clients. 69 | * 70 | * @param ContainerBuilder $container A ContainerBuilder instance 71 | */ 72 | private function loadClient($config, ContainerBuilder $container) 73 | { 74 | $clientId = ('typesense.client'); 75 | 76 | $clientDef = new ChildDefinition('typesense.client_prototype'); 77 | $clientDef->replaceArgument(0, $config['url']); 78 | $clientDef->replaceArgument(1, $config['key']); 79 | $container->setDefinition($clientId, $clientDef); 80 | 81 | $this->parameters['collection_prefix'] = $config['collection_prefix'] ?? ''; 82 | } 83 | 84 | /** 85 | * Loads the configured collection. 86 | * 87 | * @param array $collections An array of collection configurations 88 | * @param ContainerBuilder $container A ContainerBuilder instance 89 | * 90 | * @throws \InvalidArgumentException 91 | */ 92 | private function loadCollections(array $collections, ContainerBuilder $container) 93 | { 94 | foreach ($collections as $name => $config) { 95 | $collectionName = $this->parameters['collection_prefix'] . ($config['collection_name'] ?? $name); 96 | 97 | $primaryKeyExists = false; 98 | 99 | foreach ($config['fields'] as $key => $fieldConfig) { 100 | if (!isset($fieldConfig['name'])) { 101 | throw new \Exception('acseo_typesense.collections.'.$name.'.'.$key.'.name must be set'); 102 | } 103 | if (!isset($fieldConfig['type'])) { 104 | throw new \Exception('acseo_typesense.collections.'.$name.'.'.$key.'.type must be set'); 105 | } 106 | 107 | if ($fieldConfig['type'] === 'primary') { 108 | $primaryKeyExists = true; 109 | } 110 | if (!isset($fieldConfig['entity_attribute'])) { 111 | $config['fields'][$key]['entity_attribute'] = $key; 112 | } 113 | } 114 | 115 | if (!$primaryKeyExists) { 116 | $config['fields']['id'] = [ 117 | 'name' => 'entity_id', 118 | 'type' => 'primary', 119 | 'entity_attribute' => 'id' 120 | ]; 121 | } 122 | 123 | if (isset($config['finders'])) { 124 | foreach ($config['finders'] as $finderName => $finderConfig) { 125 | $finderName = $name.'.'.$finderName; 126 | $finderConfig['collection_name'] = $collectionName; 127 | $finderConfig['name'] = $name; 128 | $finderConfig['finder_name'] = $finderName; 129 | if (!isset($finderConfig['finder_parameters']['query_by'])) { 130 | throw new \Exception('acseo_typesense.collections.'.$finderName.'.finder_parameters.query_by must be set'); 131 | } 132 | $this->findersConfig[$finderName] = $finderConfig; 133 | } 134 | } 135 | 136 | $this->collectionsConfig[$name] = [ 137 | 'typesense_name' => $collectionName, 138 | 'entity' => $config['entity'], 139 | 'name' => $name, 140 | 'fields' => $config['fields'], 141 | 'default_sorting_field' => $config['default_sorting_field'], 142 | 'token_separators' => $config['token_separators'], 143 | 'symbols_to_index' => $config['symbols_to_index'], 144 | 'enable_nested_fields' => $config['enable_nested_fields'] ?? false, 145 | ]; 146 | } 147 | } 148 | 149 | /** 150 | * Loads the collection manager. 151 | */ 152 | private function loadCollectionManager(ContainerBuilder $container) 153 | { 154 | $managerDef = $container->getDefinition('typesense.collection_manager'); 155 | $managerDef->replaceArgument(2, $this->collectionsConfig); 156 | } 157 | 158 | /** 159 | * Loads the transformer. 160 | */ 161 | private function loadTransformer(ContainerBuilder $container) 162 | { 163 | $managerDef = $container->getDefinition('typesense.transformer.doctrine_to_typesense'); 164 | $managerDef->replaceArgument(0, $this->collectionsConfig); 165 | } 166 | 167 | /** 168 | * Loads the configured index finders. 169 | */ 170 | private function loadCollectionsFinder(ContainerBuilder $container) 171 | { 172 | foreach ($this->collectionsConfig as $name => $config) { 173 | $collectionName = $config['name']; 174 | 175 | $finderId = sprintf('typesense.finder.%s', $collectionName); 176 | $finderId = sprintf('typesense.finder.%s', $name); 177 | $finderDef = new ChildDefinition('typesense.finder'); 178 | $finderDef->replaceArgument(2, $config); 179 | 180 | $container->setDefinition($finderId, $finderDef); 181 | } 182 | } 183 | 184 | /** 185 | * Loads the configured Finder services. 186 | */ 187 | private function loadFinderServices(ContainerBuilder $container) 188 | { 189 | foreach ($this->findersConfig as $name => $config) { 190 | $finderName = $config['finder_name']; 191 | $collectionName = $config['name']; 192 | $finderId = sprintf('typesense.finder.%s', $collectionName); 193 | 194 | if (isset($config['finder_service'])) { 195 | $finderId = $config['finder_service']; 196 | } 197 | 198 | $specifiFinderId = sprintf('typesense.specificfinder.%s', $finderName); 199 | $specifiFinderDef = new ChildDefinition('typesense.specificfinder'); 200 | $specifiFinderDef->replaceArgument(0, new Reference($finderId)); 201 | $specifiFinderDef->replaceArgument(1, $config['finder_parameters']); 202 | 203 | $container->setDefinition($specifiFinderId, $specifiFinderDef); 204 | } 205 | } 206 | 207 | private function configureController(ContainerBuilder $container) 208 | { 209 | $finderServices = []; 210 | foreach ($this->findersConfig as $name => $config) { 211 | $finderName = $config['finder_name']; 212 | $finderId = sprintf('typesense.specificfinder.%s', $finderName); 213 | $finderServices[$finderName] = new Reference($finderId); 214 | } 215 | $controllerDef = $container->getDefinition('typesense.autocomplete_controller'); 216 | $controllerDef->replaceArgument(0, $finderServices); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode() 17 | ->children() 18 | ->arrayNode('typesense') 19 | ->info('Typesense server information') 20 | ->addDefaultsIfNotSet() 21 | ->children() 22 | ->scalarNode('url')->isRequired()->cannotBeEmpty()->end() 23 | ->scalarNode('key')->isRequired()->cannotBeEmpty()->end() 24 | ->scalarNode('collection_prefix')->end() 25 | ->end() 26 | ->end() 27 | ->arrayNode('collections') 28 | ->info('Collection definition') 29 | ->useAttributeAsKey('name') 30 | ->arrayPrototype() 31 | ->children() 32 | ->scalarNode('collection_name')->end() 33 | ->booleanNode('enable_nested_fields')->end() 34 | ->scalarNode('entity')->end() 35 | ->arrayNode('fields') 36 | ->arrayPrototype() 37 | ->children() 38 | ->scalarNode('entity_attribute')->end() 39 | ->scalarNode('name')->end() 40 | ->scalarNode('type')->end() 41 | ->booleanNode('facet')->end() 42 | ->booleanNode('infix')->end() 43 | ->booleanNode('optional')->end() 44 | ->booleanNode('sort')->end() 45 | ->arrayNode('embed') 46 | ->children() 47 | ->arrayNode('from') 48 | ->scalarPrototype()->end() 49 | ->end() 50 | ->arrayNode('model_config') 51 | ->children() 52 | ->scalarNode('model_name')->isRequired()->end() 53 | ->end() 54 | ->end() 55 | ->end() 56 | ->end() 57 | ->end() 58 | ->end() 59 | ->end() 60 | ->scalarNode('default_sorting_field')->isRequired()->cannotBeEmpty()->end() 61 | ->arrayNode('finders') 62 | ->info('Entity specific finders declaration') 63 | ->useAttributeAsKey('name') 64 | ->arrayPrototype() 65 | ->children() 66 | ->scalarNode('finder_service')->end() 67 | ->arrayNode('finder_parameters') 68 | ->scalarPrototype()->end() 69 | ->end() 70 | ->end() 71 | ->end() 72 | ->end() 73 | ->arrayNode('token_separators') 74 | ->defaultValue([]) 75 | ->scalarPrototype()->end() 76 | ->end() 77 | ->arrayNode('symbols_to_index') 78 | ->defaultValue([]) 79 | ->scalarPrototype()->end() 80 | ->end() 81 | ->end() 82 | ->end() 83 | ->end() 84 | ->end() 85 | ; 86 | 87 | return $treeBuilder; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/EventListener/TypesenseIndexer.php: -------------------------------------------------------------------------------- 1 | collectionManager = $collectionManager; 31 | $this->documentManager = $documentManager; 32 | $this->transformer = $transformer; 33 | 34 | $this->managedClassNames = $this->collectionManager->getManagedClassNames(); 35 | } 36 | 37 | public function postPersist(LifecycleEventArgs $args) 38 | { 39 | $entity = $args->getObject(); 40 | 41 | if ($this->entityIsNotManaged($entity)) { 42 | return; 43 | } 44 | 45 | $collection = $this->getCollectionName($entity); 46 | $data = $this->transformer->convert($entity); 47 | 48 | $this->documentsToIndex[] = [$collection, $data]; 49 | } 50 | 51 | public function postUpdate(LifecycleEventArgs $args) 52 | { 53 | $entity = $args->getObject(); 54 | 55 | if ($this->entityIsNotManaged($entity)) { 56 | return; 57 | } 58 | 59 | $collectionDefinitionKey = $this->getCollectionKey($entity); 60 | $collectionConfig = $this->collectionManager->getCollectionDefinitions()[$collectionDefinitionKey]; 61 | 62 | $this->checkPrimaryKeyExists($collectionConfig); 63 | 64 | $collection = $this->getCollectionName($entity); 65 | $data = $this->transformer->convert($entity); 66 | 67 | $this->documentsToUpdate[] = [$collection, $data['id'], $data]; 68 | } 69 | 70 | private function checkPrimaryKeyExists($collectionConfig) 71 | { 72 | foreach ($collectionConfig['fields'] as $config) { 73 | if ($config['type'] === 'primary') { 74 | return; 75 | } 76 | } 77 | 78 | throw new \Exception(sprintf('Primary key info have not been found for Typesense collection %s', $collectionConfig['typesense_name'])); 79 | } 80 | 81 | public function preRemove(LifecycleEventArgs $args) 82 | { 83 | $entity = $args->getObject(); 84 | 85 | if ($this->entityIsNotManaged($entity)) { 86 | return; 87 | } 88 | 89 | $data = $this->transformer->convert($entity); 90 | 91 | $this->objetsIdThatCanBeDeletedByObjectHash[spl_object_hash($entity)] = $data['id']; 92 | } 93 | 94 | public function postRemove(LifecycleEventArgs $args) 95 | { 96 | $entity = $args->getObject(); 97 | 98 | $entityHash = spl_object_hash($entity); 99 | 100 | if (!isset($this->objetsIdThatCanBeDeletedByObjectHash[$entityHash])) { 101 | return; 102 | } 103 | 104 | $collection = $this->getCollectionName($entity); 105 | 106 | $this->documentsToDelete[] = [$collection, $this->objetsIdThatCanBeDeletedByObjectHash[$entityHash]]; 107 | } 108 | 109 | public function postFlush() 110 | { 111 | $this->indexDocuments(); 112 | $this->deleteDocuments(); 113 | 114 | $this->resetDocuments(); 115 | } 116 | 117 | private function indexDocuments() 118 | { 119 | foreach ($this->documentsToIndex as $documentToIndex) { 120 | $this->documentManager->index(...$documentToIndex); 121 | } 122 | foreach ($this->documentsToUpdate as $documentToUpdate) { 123 | $this->documentManager->index($documentToUpdate[0], $documentToUpdate[2]); 124 | } 125 | } 126 | 127 | private function deleteDocuments() 128 | { 129 | foreach ($this->documentsToDelete as $documentToDelete) { 130 | $this->documentManager->delete(...$documentToDelete); 131 | } 132 | } 133 | 134 | private function resetDocuments() 135 | { 136 | $this->documentsToIndex = []; 137 | $this->documentsToUpdate = []; 138 | $this->documentsToDelete = []; 139 | } 140 | 141 | private function entityIsNotManaged($entity) 142 | { 143 | $entityClassname = ClassUtils::getClass($entity); 144 | 145 | return !in_array($entityClassname, array_values($this->managedClassNames), true); 146 | } 147 | 148 | private function getCollectionName($entity) 149 | { 150 | $entityClassname = ClassUtils::getClass($entity); 151 | 152 | return array_search($entityClassname, $this->managedClassNames, true); 153 | } 154 | 155 | private function getCollectionKey($entity) 156 | { 157 | $entityClassname = ClassUtils::getClass($entity); 158 | 159 | foreach ($this->collectionManager->getCollectionDefinitions() as $key => $def) { 160 | if ($def['entity'] === $entityClassname) { 161 | return $key; 162 | } 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/Exception/TypesenseException.php: -------------------------------------------------------------------------------- 1 | status = $response->getStatusCode(); 21 | $this->message = json_decode($response->getContent(false), true)['message'] ?? ''; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Finder/CollectionFinder.php: -------------------------------------------------------------------------------- 1 | collectionConfig = $collectionConfig; 20 | $this->collectionClient = $collectionClient; 21 | $this->em = $em; 22 | } 23 | 24 | public function rawQuery(TypesenseQuery $query) 25 | { 26 | return $this->search($query); 27 | } 28 | 29 | public function query(TypesenseQuery $query): TypesenseResponse 30 | { 31 | $results = $this->search($query); 32 | 33 | return $this->hydrate($results); 34 | } 35 | 36 | public function hydrateResponse(TypesenseResponse $response) : TypesenseResponse 37 | { 38 | return $this->hydrate($response); 39 | } 40 | 41 | /** 42 | * Add database entities to Typesense Response 43 | * 44 | * @param TypesenseResponse $results 45 | * @return TypesenseResponse 46 | */ 47 | private function hydrate(TypesenseResponse $results) : TypesenseResponse 48 | { 49 | $ids = []; 50 | $primaryKeyInfos = $this->getPrimaryKeyInfo(); 51 | foreach ($results->getResults() as $result) { 52 | $ids[] = $result['document'][$primaryKeyInfos['documentAttribute']]; 53 | } 54 | 55 | $hydratedResults = []; 56 | if (count($ids)) { 57 | $dql = sprintf( 58 | 'SELECT e FROM %s e WHERE e.%s IN (:ids)', 59 | $this->collectionConfig['entity'], 60 | $primaryKeyInfos['entityAttribute'] 61 | ); 62 | 63 | $query = $this->em->createQuery($dql); 64 | $query->setParameter('ids', $ids); 65 | 66 | $unorderedResults = $query->getResult(); 67 | 68 | // sort index 69 | $idIndex = array_flip($ids); 70 | 71 | usort($unorderedResults, function ($a, $b) use ($idIndex, $primaryKeyInfos) { 72 | $entityIdMethod = 'get' . ucfirst($primaryKeyInfos['entityAttribute']); 73 | $idA = $a->$entityIdMethod(); 74 | $idB = $b->$entityIdMethod(); 75 | 76 | return $idIndex[$idA] <=> $idIndex[$idB]; 77 | }); 78 | 79 | $hydratedResults = $unorderedResults; 80 | 81 | } 82 | $results->setHydratedHits($hydratedResults); 83 | $results->setHydrated(true); 84 | 85 | return $results; 86 | } 87 | 88 | private function search(TypesenseQuery $query) : TypesenseResponse 89 | { 90 | $result = $this->collectionClient->search($this->collectionConfig['typesense_name'], $query); 91 | 92 | return new TypesenseResponse($result); 93 | } 94 | 95 | private function getPrimaryKeyInfo() 96 | { 97 | foreach ($this->collectionConfig['fields'] as $name => $config) { 98 | if ($config['type'] === 'primary') { 99 | return ['entityAttribute' => $config['entity_attribute'], 'documentAttribute' => $config['name']]; 100 | } 101 | } 102 | 103 | throw new \Exception(sprintf('Primary key info have not been found for Typesense collection %s', $this->collectionConfig['typesense_name'])); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Finder/CollectionFinderInterface.php: -------------------------------------------------------------------------------- 1 | finder = $finder; 15 | $this->arguments = $arguments; 16 | } 17 | 18 | public function search($query): TypesenseResponse 19 | { 20 | $queryBy = $this->arguments['query_by']; 21 | $query = new TypesenseQuery($query, $queryBy); 22 | unset($this->arguments['query_by']); 23 | foreach ($this->arguments as $key => $value) { 24 | $query->addParameter($key, $value); 25 | } 26 | 27 | return $this->finder->query($query); 28 | //$rawResults = $response->getRawResults(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Finder/SpecificCollectionFinderInterface.php: -------------------------------------------------------------------------------- 1 | searchParameters = []; 14 | if ($q !== null) { 15 | $this->addParameter('q', $q); 16 | } 17 | if ($queryBy !== null) { 18 | $this->addParameter('query_by', $queryBy); 19 | } 20 | 21 | return $this; 22 | } 23 | 24 | public function getParameters(): array 25 | { 26 | return $this->searchParameters; 27 | } 28 | 29 | public function hasParameter($key): bool 30 | { 31 | return isset($this->searchParameters[$key]) ? true : false; 32 | } 33 | 34 | /** 35 | * Maximum number of hits returned. Increasing this value might increase search latency. Use all to return all hits found. 36 | * 37 | * @param [type] $maxHits 38 | */ 39 | public function maxHits($maxHits): self 40 | { 41 | return $this->addParameter('max_hits', $maxHits); 42 | } 43 | 44 | /** 45 | * Boolean field to indicate that the last word in the query should be treated as a prefix, and not as a whole word. This is necessary for building autocomplete and instant search interfaces. 46 | */ 47 | public function prefix(bool $prefix): self 48 | { 49 | return $this->addParameter('prefix', $prefix); 50 | } 51 | 52 | /** 53 | * Filter conditions for refining your search results. A field can be matched against one or more values. 54 | */ 55 | public function filterBy(string $filterBy): self 56 | { 57 | return $this->addParameter('filter_by', $filterBy); 58 | } 59 | 60 | /** 61 | * A list of numerical fields and their corresponding sort orders that will be used for ordering your results. Separate multiple fields with a comma. Upto 3 sort fields can be specified. 62 | */ 63 | public function sortBy(string $sortBy): self 64 | { 65 | return $this->addParameter('sort_by', $sortBy); 66 | } 67 | 68 | /** 69 | * A list of fields that will be used for querying your results on. Separate multiple fields with a comma. 70 | */ 71 | public function infix(string $infix): self 72 | { 73 | return $this->addParameter('infix', $infix); 74 | } 75 | 76 | /** 77 | * A list of fields that will be used for faceting your results on. Separate multiple fields with a comma. 78 | */ 79 | public function facetBy(string $facetBy): self 80 | { 81 | return $this->addParameter('facet_by', $facetBy); 82 | } 83 | 84 | /** 85 | * Maximum number of facet values to be returned. 86 | */ 87 | public function maxFacetValues(int $maxFacetValues): self 88 | { 89 | return $this->addParameter('max_facet_values', $maxFacetValues); 90 | } 91 | 92 | /** 93 | * Facet values that are returned can now be filtered via this parameter. The matching facet text is also highlighted. For example, when faceting by category, you can set facet_query=category:shoe to return only facet values that contain the prefix "shoe". 94 | */ 95 | public function facetQuery(string $facetQuery): self 96 | { 97 | return $this->addParameter('facet_query', $facetQuery); 98 | } 99 | 100 | /** 101 | * Number of typographical errors (1 or 2) that would be tolerated. 102 | */ 103 | public function numTypos(int $numTypos): self 104 | { 105 | return $this->addParameter('num_typos', $numTypos); 106 | } 107 | 108 | /** 109 | * Results from this specific page number would be fetched. 110 | */ 111 | public function page(int $page): self 112 | { 113 | return $this->addParameter('page', $page); 114 | } 115 | 116 | /** 117 | * Number of results to fetch per page. 118 | */ 119 | public function perPage(int $perPage): self 120 | { 121 | return $this->addParameter('per_page', $perPage); 122 | } 123 | 124 | /** 125 | * You can aggregate search results into groups or buckets by specify one or more group_by fields. Separate multiple fields with a comma. 126 | */ 127 | public function groupBy(string $groupBy): self 128 | { 129 | return $this->addParameter('group_by', $groupBy); 130 | } 131 | 132 | /** 133 | * Maximum number of hits to be returned for every group. If the group_limit is set as K then only the top K hits in each group are returned in the response. 134 | */ 135 | public function groupLimit(int $groupLimit): self 136 | { 137 | return $this->addParameter('group_limit', $groupLimit); 138 | } 139 | 140 | /** 141 | * Comma-separated list of fields from the document to include in the search result. 142 | */ 143 | public function includeFields(string $includeFields): self 144 | { 145 | return $this->addParameter('include_fields', $includeFields); 146 | } 147 | 148 | /** 149 | * Comma-separated list of fields from the document to exclude in the search result. 150 | */ 151 | public function excludeFields(string $excludeFields): self 152 | { 153 | return $this->addParameter('exclude_fields', $excludeFields); 154 | } 155 | 156 | /** 157 | * Comma separated list of fields which should be highlighted fully without snippeting. 158 | */ 159 | public function highlightFullFields(string $highlightFullFields): self 160 | { 161 | return $this->addParameter('highlight_full_fields', $highlightFullFields); 162 | } 163 | 164 | /** 165 | * Field values under this length will be fully highlighted, instead of showing a snippet of relevant portion. 166 | */ 167 | public function snippetThreshold(int $snippetThreshold): self 168 | { 169 | return $this->addParameter('snippet_threshold', $snippetThreshold); 170 | } 171 | 172 | /** 173 | * If the number of results found for a specific query is less than this number, Typesense will attempt to drop the tokens in the query until enough results are found. Tokens that have the least individual hits are dropped first. Set drop_tokens_threshold to 0 to disable dropping of tokens. 174 | */ 175 | public function dropTokensThreshold(int $dropTokensThreshold): self 176 | { 177 | return $this->addParameter('drop_tokens_threshold', $dropTokensThreshold); 178 | } 179 | 180 | /** 181 | * If the number of results found for a specific query is less than this number, Typesense will attempt to look for tokens with more typos until enough results are found. 182 | */ 183 | public function typoTokensThreshold(int $typoTokensThreshold): self 184 | { 185 | return $this->addParameter('typo_tokens_threshold', $typoTokensThreshold); 186 | } 187 | 188 | /** 189 | * A list of records to unconditionally include in the search results at specific positions. 190 | * An example use case would be to feature or promote certain items on the top of search results. 191 | * A comma separated list of record_id:hit_position. Eg: to include a record with ID 123 at Position 1 and another record with ID 456 at Position 5, you'd specify 123:1,456:5. 192 | * You could also use the Overrides feature to override search results based on rules. Overrides are applied first, followed by pinned_hits and finally hidden_hits. 193 | */ 194 | public function pinnedHits(string $pinnedHits): self 195 | { 196 | return $this->addParameter('pinned_hits', $pinnedHits); 197 | } 198 | 199 | /** 200 | * A list of records to unconditionally hide from search results. 201 | * A comma separated list of record_ids to hide. Eg: to hide records with IDs 123 and 456, you'd specify 123,456. 202 | * You could also use the Overrides feature to override search results based on rules. Overrides are applied first, followed by pinned_hits and finally hidden_hits. 203 | */ 204 | public function hiddenHits(string $hiddenHits): self 205 | { 206 | return $this->addParameter('hidden_hits', $hiddenHits); 207 | } 208 | 209 | /** 210 | * Generic method that allows to add any parameter to the TypesenseQuery. 211 | */ 212 | public function addParameter($key, $value): self 213 | { 214 | $this->searchParameters[$key] = $value; 215 | 216 | return $this; 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/Finder/TypesenseResponse.php: -------------------------------------------------------------------------------- 1 | facetCounts = $result['facet_counts'] ?? null; 20 | $this->found = $result['found'] ?? null; 21 | $this->hits = $result['hits'] ?? null; 22 | $this->page = $result['page'] ?? null; 23 | $this->searchTimeMs = $result['search_time_ms'] ?? null; 24 | $this->isHydrated = false; 25 | $this->hydratedHits = null; 26 | } 27 | 28 | /** 29 | * Get the value of facetCounts. 30 | */ 31 | public function getFacetCounts() 32 | { 33 | return $this->facetCounts; 34 | } 35 | 36 | /** 37 | * Get the value of hits. 38 | */ 39 | public function getResults() 40 | { 41 | if ($this->isHydrated) { 42 | return $this->hydratedHits; 43 | } 44 | 45 | return $this->hits; 46 | } 47 | 48 | public function getRawResults() 49 | { 50 | return $this->hits; 51 | } 52 | 53 | /** 54 | * Get the value of page. 55 | */ 56 | public function getPage() 57 | { 58 | return $this->page; 59 | } 60 | 61 | /** 62 | * Get total hits. 63 | */ 64 | public function getFound() 65 | { 66 | return $this->found; 67 | } 68 | 69 | /** 70 | * Set the value of hydratedHits. 71 | */ 72 | public function setHydratedHits($hydratedHits): self 73 | { 74 | $this->hydratedHits = $hydratedHits; 75 | 76 | return $this; 77 | } 78 | 79 | /** 80 | * Set the value of isHydrated. 81 | */ 82 | public function setHydrated(bool $isHydrated): self 83 | { 84 | $this->isHydrated = $isHydrated; 85 | 86 | return $this; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Manager/CollectionManager.php: -------------------------------------------------------------------------------- 1 | collectionDefinitions = $collectionDefinitions; 19 | $this->collectionClient = $collectionClient; 20 | $this->transformer = $transformer; 21 | } 22 | 23 | public function getCollectionDefinitions() 24 | { 25 | return $this->collectionDefinitions; 26 | } 27 | 28 | public function getManagedClassNames() 29 | { 30 | $managedClassNames = []; 31 | foreach ($this->collectionDefinitions as $name => $collectionDefinition) { 32 | $collectionName = $collectionDefinition['typesense_name'] ?? $name; 33 | $managedClassNames[$collectionName] = $collectionDefinition['entity']; 34 | } 35 | 36 | return $managedClassNames; 37 | } 38 | 39 | public function getAllCollections() 40 | { 41 | return $this->collectionClient->list(); 42 | } 43 | 44 | public function createAllCollections() 45 | { 46 | foreach ($this->collectionDefinitions as $name => $collectionDefinition) { 47 | $this->createCollection($name); 48 | } 49 | } 50 | 51 | public function deleteCollection($collectionDefinitionName) 52 | { 53 | $definition = $this->collectionDefinitions[$collectionDefinitionName]; 54 | $this->collectionClient->delete($definition['typesense_name']); 55 | } 56 | 57 | public function deleteCollextion($collectionDefinitionName) 58 | { 59 | return $this->deleteCollection($collectionDefinitionName); 60 | } 61 | 62 | public function createCollection($collectionDefinitionName) 63 | { 64 | $definition = $this->collectionDefinitions[$collectionDefinitionName]; 65 | $fieldDefinitions = $definition['fields']; 66 | $fields = []; 67 | foreach ($fieldDefinitions as $key => $fieldDefinition) { 68 | $fieldDefinition['type'] = $this->transformer->castType($fieldDefinition['type']); 69 | $fields[] = $fieldDefinition; 70 | } 71 | 72 | //to pass the tests 73 | $tokenSeparators = array_key_exists('token_separators', $definition) ? $definition['token_separators'] : []; 74 | $symbolsToIndex = array_key_exists('symbols_to_index', $definition) ? $definition['symbols_to_index'] : []; 75 | 76 | $this->collectionClient->create( 77 | $definition['typesense_name'], 78 | $fields, 79 | $definition['default_sorting_field'], 80 | $tokenSeparators, 81 | $symbolsToIndex, 82 | $definition['enable_nested_fields'] ?? false, 83 | $definition['embed'] ?? null 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Manager/DocumentManager.php: -------------------------------------------------------------------------------- 1 | client = $client; 16 | } 17 | 18 | public function delete($collection, $id) 19 | { 20 | if (!$this->client->isOperationnal()) { 21 | return null; 22 | } 23 | 24 | return $this->client->collections[$collection]->documents[$id]->delete(); 25 | } 26 | 27 | public function index($collection, $data) 28 | { 29 | if (!$this->client->isOperationnal()) { 30 | return null; 31 | } 32 | 33 | return $this->client->collections[$collection]->documents->upsert($data); 34 | } 35 | 36 | public function import(string $collection, array $data, string $action = 'create') 37 | { 38 | if (!$this->client->isOperationnal() || empty($data)) { 39 | return []; 40 | } 41 | 42 | return $this->client->collections[$collection]->documents->import($data, ['action' => $action]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Resources/config/commands.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acseo/TypesenseBundle/99d88d6669c7ba1479c80a887a3911d4fec8126b/src/Resources/config/commands.xml -------------------------------------------------------------------------------- /src/Resources/config/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 27 | 28 | 29 | 30 | 31 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /src/Transformer/AbstractTransformer.php: -------------------------------------------------------------------------------- 1 | collectionDefinitions = $collectionDefinitions; 22 | $this->accessor = $accessor; 23 | $this->container = $container; 24 | 25 | $this->entityToCollectionMapping = []; 26 | foreach ($this->collectionDefinitions as $collection => $collectionDefinition) { 27 | $this->entityToCollectionMapping[$collectionDefinition['entity']] = $collection; 28 | } 29 | } 30 | 31 | public function convert($entity): array 32 | { 33 | $entityClass = ClassUtils::getClass($entity); 34 | 35 | // See : https://github.com/acseo/TypesenseBundle/pull/91 36 | // Allow subclasses to be recognized as a parent class 37 | foreach (array_keys($this->entityToCollectionMapping) as $class) { 38 | if (is_a($entityClass, $class, true)) { 39 | $entityClass = $class; 40 | break; 41 | } 42 | } 43 | 44 | 45 | if (!isset($this->entityToCollectionMapping[$entityClass])) { 46 | throw new \Exception(sprintf('Class %s is not supported for Doctrine To Typesense Transformation', $entityClass)); 47 | } 48 | 49 | $data = []; 50 | 51 | $fields = $this->collectionDefinitions[$this->entityToCollectionMapping[$entityClass]]['fields']; 52 | 53 | foreach ($fields as $fieldsInfo) { 54 | if ($fieldsInfo['embed'] ?? false) { 55 | // skip fields with embed attribute since its an autogenerated value 56 | continue; 57 | } 58 | 59 | $entityAttribute = $fieldsInfo['entity_attribute']; 60 | 61 | if (str_contains($entityAttribute, '::')) { 62 | $value = $this->getFieldValueFromService($entity, $entityAttribute); 63 | } else { 64 | try { 65 | $value = $this->accessor->getValue($entity, $fieldsInfo['entity_attribute']); 66 | } catch (RuntimeException $exception) { 67 | $value = null; 68 | } 69 | } 70 | 71 | $name = $fieldsInfo['name']; 72 | 73 | $data[$name] = $this->castValue( 74 | $entityClass, 75 | $name, 76 | $value 77 | ); 78 | } 79 | 80 | return $data; 81 | } 82 | 83 | public function castValue(string $entityClass, string $propertyName, $value) 84 | { 85 | $collection = $this->entityToCollectionMapping[$entityClass]; 86 | $key = array_search( 87 | $propertyName, 88 | array_column( 89 | $this->collectionDefinitions[$collection]['fields'], 90 | 'name' 91 | ), true 92 | ); 93 | $collectionFieldsDefinitions = array_values($this->collectionDefinitions[$collection]['fields']); 94 | $originalType = $collectionFieldsDefinitions[$key]['type']; 95 | $castedType = $this->castType($originalType); 96 | 97 | $isOptional = $collectionFieldsDefinitions[$key]['optional'] ?? false; 98 | 99 | switch ($originalType.$castedType) { 100 | case self::TYPE_DATETIME.self::TYPE_INT_64: 101 | if ($value instanceof \DateTimeInterface) { 102 | return $value->getTimestamp(); 103 | } 104 | 105 | return null; 106 | case self::TYPE_OBJECT.self::TYPE_STRING: 107 | if ($isOptional == true && $value == null) { 108 | return null; 109 | } 110 | return $value->__toString(); 111 | case self::TYPE_COLLECTION.self::TYPE_ARRAY_STRING: 112 | return array_values( 113 | $value->map(function ($v) use($isOptional) { 114 | if ($isOptional == true && $v == null) { 115 | return null; 116 | } 117 | return $v->__toString(); 118 | })->toArray() 119 | ); 120 | case self::TYPE_STRING.self::TYPE_STRING: 121 | case self::TYPE_PRIMARY.self::TYPE_STRING: 122 | return (string) $value; 123 | case self::TYPE_BOOL.self::TYPE_BOOL: 124 | return (bool) $value; 125 | default: 126 | return $value; 127 | } 128 | } 129 | 130 | private function getFieldValueFromService($entity, $entityAttribute) 131 | { 132 | $values = explode('::', $entityAttribute); 133 | 134 | if (count($values) === 2) { 135 | if ($this->container->has($values[0])) { 136 | $service = $this->container->get($values[0]); 137 | return call_user_func(array($service, $values[1]), $entity); 138 | } 139 | } 140 | 141 | return null; 142 | } 143 | 144 | } 145 | -------------------------------------------------------------------------------- /tests/Functional/AllowNullConnexionTest.php: -------------------------------------------------------------------------------- 1 | createCommandTester(); 46 | $commandTester->execute(['-vvv']); 47 | 48 | // the output of the command in the console 49 | $output = $commandTester->getDisplay(); 50 | self::assertStringContainsString('Deleting books', $output); 51 | self::assertStringContainsString('Creating books', $output); 52 | } 53 | 54 | /** 55 | * @depends testCreateCommand 56 | */ 57 | public function testImportCommand() 58 | { 59 | $commandTester = $this->importCommandTester(); 60 | $commandTester->execute([]); 61 | 62 | // the output of the command in the console 63 | $output = $commandTester->getDisplay(); 64 | self::assertStringContainsString('[books] ACSEO\TypesenseBundle\Tests\Functional\Entity\Book', $output); 65 | self::assertStringContainsString('[OK] '.self::NB_BOOKS.' elements populated', $output); 66 | } 67 | 68 | /** 69 | * @depends testImportCommand 70 | */ 71 | public function testSearchByAuthor() 72 | { 73 | $typeSenseClient = new TypesenseClient('null', 'null'); 74 | $collectionClient = new CollectionClient($typeSenseClient); 75 | $book = new Book(1, 'test', new Author('Nicolas Potier', 'France'), new \DateTime()); 76 | $em = $this->getMockedEntityManager([$book]); 77 | $collectionDefinitions = $this->getCollectionDefinitions(\get_class($book)); 78 | $bookDefinition = $collectionDefinitions['books']; 79 | 80 | $bookFinder = new CollectionFinder($collectionClient, $em, $bookDefinition); 81 | $results = $bookFinder->rawQuery(new TypesenseQuery('Nicolas', 'author'))->getResults(); 82 | self::assertNull($results); 83 | } 84 | 85 | private function createCommandTester(): CommandTester 86 | { 87 | $application = new Application(); 88 | 89 | $application->setAutoExit(false); 90 | 91 | $book = $this->getMockBuilder('\App\Entity\Book')->getMock(); 92 | // Author is required 93 | $author = $this->getMockBuilder('\App\Entity\Author')->getMock(); 94 | 95 | $collectionDefinitions = $this->getCollectionDefinitions(\get_class($book)); 96 | $typeSenseClient = new TypesenseClient('null', 'null'); 97 | $propertyAccessor = PropertyAccess::createPropertyAccessor(); 98 | $collectionClient = new CollectionClient($typeSenseClient); 99 | $container = $this->createMock(ContainerInterface::class); 100 | $transformer = new DoctrineToTypesenseTransformer($collectionDefinitions, $propertyAccessor, $container); 101 | $collectionManager = new CollectionManager($collectionClient, $transformer, $collectionDefinitions); 102 | 103 | $command = new CreateCommand($collectionManager); 104 | 105 | $application->add($command); 106 | 107 | return new CommandTester($application->find('typesense:create')); 108 | } 109 | 110 | private function importCommandTester(): CommandTester 111 | { 112 | $application = new Application(); 113 | 114 | $application->setAutoExit(false); 115 | 116 | // Prepare all mocked objects required to run the command 117 | $books = $this->getMockedBooks(); 118 | $collectionDefinitions = $this->getCollectionDefinitions(Book::class); 119 | $typeSenseClient = new TypesenseClient('null', 'null'); 120 | $propertyAccessor = PropertyAccess::createPropertyAccessor(); 121 | $collectionClient = new CollectionClient($typeSenseClient); 122 | $container = $this->createMock(ContainerInterface::class); 123 | $transformer = new DoctrineToTypesenseTransformer($collectionDefinitions, $propertyAccessor, $container); 124 | $documentManager = new DocumentManager($typeSenseClient); 125 | $collectionManager = new CollectionManager($collectionClient, $transformer, $collectionDefinitions); 126 | $em = $this->getMockedEntityManager($books); 127 | 128 | $command = new ImportCommand($em, $collectionManager, $documentManager, $transformer); 129 | 130 | $application->add($command); 131 | 132 | return new CommandTester($application->find('typesense:import')); 133 | } 134 | 135 | private function getCollectionDefinitions($entityClass) 136 | { 137 | return [ 138 | 'books' => [ 139 | 'typesense_name' => 'books', 140 | 'entity' => $entityClass, 141 | 'name' => 'books', 142 | 'fields' => [ 143 | 'id' => [ 144 | 'name' => 'id', 145 | 'type' => 'primary', 146 | 'entity_attribute' => 'id', 147 | ], 148 | 'sortable_id' => [ 149 | 'entity_attribute' => 'id', 150 | 'name' => 'sortable_id', 151 | 'type' => 'int32', 152 | ], 153 | 'title' => [ 154 | 'name' => 'title', 155 | 'type' => 'string', 156 | 'entity_attribute' => 'title', 157 | ], 158 | 'author' => [ 159 | 'name' => 'author', 160 | 'type' => 'object', 161 | 'entity_attribute' => 'author', 162 | ], 163 | 'michel' => [ 164 | 'name' => 'author_country', 165 | 'type' => 'string', 166 | 'entity_attribute' => 'author.country', 167 | ], 168 | 'publishedAt' => [ 169 | 'name' => 'published_at', 170 | 'type' => 'datetime', 171 | 'optional' => true, 172 | 'entity_attribute' => 'publishedAt', 173 | ], 174 | ], 175 | 'default_sorting_field' => 'sortable_id', 176 | ], 177 | ]; 178 | } 179 | 180 | private function getMockedBooks() 181 | { 182 | $author = new Author('Nicolas Potier', 'France'); 183 | $books = []; 184 | 185 | for ($i = 0; $i < self::NB_BOOKS; ++$i) { 186 | $books[] = new Book($i, self::BOOK_TITLES[$i], $author, new \DateTime()); 187 | } 188 | 189 | return $books; 190 | } 191 | 192 | private function getMockedEntityManager($books) 193 | { 194 | $em = $this->createMock(EntityManager::class); 195 | 196 | $connection = $this->createMock(Connection::class); 197 | $em->method('getConnection')->willReturn($connection); 198 | 199 | $configuration = $this->createMock(Configuration::class); 200 | $connection->method('getConfiguration')->willReturn($configuration); 201 | 202 | $query = $this->createMock(Query::class); 203 | $em->method('createQuery')->willReturn($query); 204 | 205 | $query->method('setFirstResult')->willReturn($query); 206 | $query->method('setMaxResults')->willReturn($query); 207 | 208 | $query->method('getSingleScalarResult')->willReturn(self::NB_BOOKS); 209 | 210 | $query->method('toIterable')->willReturn($books); 211 | 212 | return $em; 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /tests/Functional/Entity/Author.php: -------------------------------------------------------------------------------- 1 | name = $name; 15 | $this->country = $country; 16 | } 17 | 18 | public function __toString() 19 | { 20 | return $this->name; 21 | } 22 | 23 | /** 24 | * Get the value of name. 25 | */ 26 | public function getName() 27 | { 28 | return $this->name; 29 | } 30 | 31 | /** 32 | * Set the value of name. 33 | */ 34 | public function setName($name): self 35 | { 36 | $this->name = $name; 37 | 38 | return $this; 39 | } 40 | 41 | /** 42 | * Get the value of country. 43 | */ 44 | public function getCountry() 45 | { 46 | return $this->country; 47 | } 48 | 49 | /** 50 | * Set the value of country. 51 | */ 52 | public function setCountry($country): self 53 | { 54 | $this->country = $country; 55 | 56 | return $this; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/Functional/Entity/Book.php: -------------------------------------------------------------------------------- 1 | id = $id; 18 | $this->title = $title; 19 | $this->author = $author; 20 | $this->publishedAt = $publishedAt; 21 | $this->active = $active; 22 | } 23 | 24 | /** 25 | * Get the value of id. 26 | */ 27 | public function getId() 28 | { 29 | return $this->id; 30 | } 31 | 32 | /** 33 | * Set the value of id. 34 | */ 35 | public function setId($id): self 36 | { 37 | $this->id = $id; 38 | 39 | return $this; 40 | } 41 | 42 | /** 43 | * Get the value of title. 44 | */ 45 | public function getTitle() 46 | { 47 | return $this->title; 48 | } 49 | 50 | /** 51 | * Set the value of title. 52 | */ 53 | public function setTitle($title): self 54 | { 55 | $this->title = $title; 56 | 57 | return $this; 58 | } 59 | 60 | /** 61 | * Set the value of author. 62 | */ 63 | public function setAuthor($author): self 64 | { 65 | $this->author = $author; 66 | 67 | return $this; 68 | } 69 | 70 | /** 71 | * Get the value of author. 72 | */ 73 | public function getAuthor() 74 | { 75 | return $this->author; 76 | } 77 | 78 | /** 79 | * Get the value of publishedAt. 80 | */ 81 | public function getPublishedAt() 82 | { 83 | return $this->publishedAt; 84 | } 85 | 86 | /** 87 | * Set the value of publishedAt. 88 | */ 89 | public function setPublishedAt($publishedAt): self 90 | { 91 | $this->publishedAt = $publishedAt; 92 | 93 | return $this; 94 | } 95 | 96 | /** 97 | * Get the value of active. 98 | */ 99 | public function getActive() 100 | { 101 | return $this->active; 102 | } 103 | 104 | /** 105 | * Get the value of active. 106 | */ 107 | public function setActive(bool $active) 108 | { 109 | $this->active = $active; 110 | 111 | return $this; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tests/Functional/Entity/BookOnline.php: -------------------------------------------------------------------------------- 1 | url; 14 | } 15 | 16 | public function setUrl(string $url) 17 | { 18 | $this->url = $url; 19 | } 20 | } -------------------------------------------------------------------------------- /tests/Functional/Service/BookConverter.php: -------------------------------------------------------------------------------- 1 | getId()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/Functional/Service/ExceptionBookConverter.php: -------------------------------------------------------------------------------- 1 | createCommandTester(); 50 | $commandTester->execute(['-vvv']); 51 | 52 | // the output of the command in the console 53 | $output = $commandTester->getDisplay(); 54 | self::assertStringContainsString('Deleting books', $output); 55 | self::assertStringContainsString('Creating books', $output); 56 | } 57 | 58 | /** 59 | * @depends testCreateCommand 60 | * @dataProvider importCommandProvider 61 | */ 62 | public function testImportCommand($nbBooks, $maxPerPage = null, $firstPage = null, $lastPage = null, $expectedCount = null) 63 | { 64 | $commandTester = $this->importCommandTester([ 65 | 'nbBooks' => $nbBooks, 66 | 'maxPerPage' => $maxPerPage, 67 | 'firstPage' => $firstPage, 68 | 'lastPage' => $lastPage 69 | ]); 70 | 71 | $commandOptions = ['-vvv']; 72 | 73 | if ($maxPerPage != null) { 74 | $commandOptions['--max-per-page'] = $maxPerPage; 75 | } 76 | if ($firstPage != null) { 77 | $commandOptions['--first-page'] = $firstPage; 78 | } 79 | if ($lastPage != null) { 80 | $commandOptions['--last-page'] = $lastPage; 81 | } 82 | 83 | $commandTester->execute($commandOptions); 84 | 85 | // the output of the command in the console 86 | $output = $commandTester->getDisplay(); 87 | 88 | self::assertStringContainsString( 89 | sprintf('[books] ACSEO\TypesenseBundle\Tests\Functional\Entity\Book %s entries to insert', $nbBooks), 90 | $output 91 | ); 92 | self::assertStringContainsString( 93 | sprintf('[OK] %s elements populated', $expectedCount ?? $nbBooks), 94 | $output 95 | ); 96 | } 97 | 98 | public function importCommandProvider() 99 | { 100 | return [ 101 | "insert 10 books one by one" => [ 102 | 10, 1 103 | ], 104 | "insert 42 books 10 by 10" => [ 105 | 42, 10 106 | ], 107 | "insert 130 books 100 per 100" => [ 108 | 130, null //100 is by defaut 109 | ], 110 | "insert 498 books 50 per 50, from page 8 to 10 and expect 148 inserted" => [ 111 | 498, 50, 8, 10, 148 112 | ] 113 | ]; 114 | } 115 | 116 | /** 117 | * @depends testImportCommand 118 | * @dataProvider importCommandProvider 119 | */ 120 | public function testSearchByAuthor($nbBooks) 121 | { 122 | $typeSenseClient = new TypesenseClient($_ENV['TYPESENSE_URL'], $_ENV['TYPESENSE_KEY']); 123 | $collectionClient = new CollectionClient($typeSenseClient); 124 | $book = new Book(1, 'test', new Author('Nicolas Potier', 'France'), new \DateTime()); 125 | $em = $this->getMockedEntityManager([$book]); 126 | $collectionDefinitions = $this->getCollectionDefinitions(Book::class); 127 | $bookDefinition = $collectionDefinitions['books']; 128 | 129 | $bookFinder = new CollectionFinder($collectionClient, $em, $bookDefinition); 130 | $query = new TypesenseQuery('Nicolas', 'author'); 131 | 132 | $query->maxHits($nbBooks < 250 ? $nbBooks : 250); 133 | $query->perPage($nbBooks < 250 ? $nbBooks : 250); 134 | 135 | $results = $bookFinder->rawQuery($query)->getResults(); 136 | 137 | self::assertCount(($nbBooks < 250 ? $nbBooks : 250), $results, "result doesn't contains ".$nbBooks.' elements'); 138 | self::assertArrayHasKey('document', $results[0], "First item does not have the key 'document'"); 139 | self::assertArrayHasKey('highlights', $results[0], "First item does not have the key 'highlights'"); 140 | self::assertArrayHasKey('text_match', $results[0], "First item does not have the key 'text_match'"); 141 | } 142 | 143 | /** 144 | * @depends testImportCommand 145 | * @dataProvider importCommandProvider 146 | */ 147 | public function testSearchByTitle($nbBooks) 148 | { 149 | $typeSenseClient = new TypesenseClient($_ENV['TYPESENSE_URL'], $_ENV['TYPESENSE_KEY']); 150 | $collectionClient = new CollectionClient($typeSenseClient); 151 | $book = new Book(1, 'test', new Author('Nicolas Potier', 'France'), new \DateTime()); 152 | 153 | $em = $this->getMockedEntityManager([$book]); 154 | $collectionDefinitions = $this->getCollectionDefinitions(Book::class); 155 | $bookDefinition = $collectionDefinitions['books']; 156 | 157 | $bookFinder = new CollectionFinder($collectionClient, $em, $bookDefinition); 158 | $query = new TypesenseQuery(self::BOOK_TITLES[0], 'title'); 159 | $query->numTypos(0); 160 | $results = $bookFinder->rawQuery($query)->getResults(); 161 | self::assertCount(1, $results, "result doesn't contains 1 elements"); 162 | self::assertArrayHasKey('document', $results[0], "First item does not have the key 'document'"); 163 | self::assertArrayHasKey('highlights', $results[0], "First item does not have the key 'highlights'"); 164 | self::assertArrayHasKey('text_match', $results[0], "First item does not have the key 'text_match'"); 165 | } 166 | 167 | /** 168 | * @depends testImportCommand 169 | */ 170 | public function testCreateAndDelete() 171 | { 172 | $book = new Book(1000, 'ACSEO', new Author('Nicolas Potier', 'France'), new \DateTime()); 173 | 174 | $typeSenseClient = new TypesenseClient($_ENV['TYPESENSE_URL'], $_ENV['TYPESENSE_KEY']); 175 | $collectionClient = new CollectionClient($typeSenseClient); 176 | $collectionDefinitions = $this->getCollectionDefinitions(Book::class); 177 | $propertyAccessor = PropertyAccess::createPropertyAccessor(); 178 | $container = $this->createMock(ContainerInterface::class); 179 | $transformer = new DoctrineToTypesenseTransformer($collectionDefinitions, $propertyAccessor, $container); 180 | $collectionManager = new CollectionManager($collectionClient, $transformer, $collectionDefinitions); 181 | $typeSenseClient = new TypesenseClient($_ENV['TYPESENSE_URL'], $_ENV['TYPESENSE_KEY']); 182 | $documentManager = new DocumentManager($typeSenseClient); 183 | $collectionDefinitions = $this->getCollectionDefinitions(Book::class); 184 | $bookDefinition = $collectionDefinitions['books']; 185 | $em = $this->getMockedEntityManager([$book]); 186 | $bookFinder = new CollectionFinder($collectionClient, $em, $bookDefinition); 187 | 188 | // Init the listener 189 | $listener = new TypesenseIndexer($collectionManager, $documentManager, $transformer); 190 | // Create a LifecycleEventArgs with a book 191 | $event = $this->getmockedEventCreate($book); 192 | 193 | // First Persist 194 | $listener->postPersist($event); 195 | // First Flush 196 | $listener->postFlush($event); 197 | 198 | $query = new TypesenseQuery('ACSEO', 'title'); 199 | $query->numTypos(0); 200 | $results = $bookFinder->rawQuery($query)->getResults(); 201 | self::assertCount(1, $results, "result doesn't contains 1 elements"); 202 | 203 | // Update the object 204 | $book->setTitle('MARSEILLE'); 205 | $event = $this->getmockedEventCreate($book); 206 | 207 | // First Update 208 | $listener->postUpdate($event); 209 | // Second Flush 210 | $listener->postFlush($event); 211 | 212 | // We should not find this book title 213 | $query = new TypesenseQuery('ACSEO', 'title'); 214 | $query->numTypos(0); 215 | $results = $bookFinder->rawQuery($query)->getResults(); 216 | self::assertCount(0, $results, "result doesn't contains 1 elements"); 217 | 218 | // But we should find this title 219 | $query = new TypesenseQuery('MARSEILLE', 'title'); 220 | $query->numTypos(0); 221 | $results = $bookFinder->rawQuery($query)->getResults(); 222 | self::assertCount(1, $results, "result doesn't contains 1 elements"); 223 | 224 | // First Remove 225 | $listener->preRemove($event); 226 | $listener->postRemove($event); 227 | // Third Flush 228 | $listener->postFlush($event); 229 | 230 | // We should not find this book title 231 | $query = new TypesenseQuery('MARSEILLE', 'title'); 232 | $query->numTypos(0); 233 | $results = $bookFinder->rawQuery($query)->getResults(); 234 | self::assertCount(0, $results, "result doesn't contains 0 elements"); 235 | } 236 | 237 | private function createCommandTester(): CommandTester 238 | { 239 | $application = new Application(); 240 | 241 | $application->setAutoExit(false); 242 | 243 | $book = $this->getMockBuilder('\App\Entity\Book')->getMock(); 244 | // Author is required 245 | $author = $this->getMockBuilder('\App\Entity\Author')->getMock(); 246 | 247 | $collectionDefinitions = $this->getCollectionDefinitions(Book::class); 248 | $typeSenseClient = new TypesenseClient($_ENV['TYPESENSE_URL'], $_ENV['TYPESENSE_KEY']); 249 | $propertyAccessor = PropertyAccess::createPropertyAccessor(); 250 | $collectionClient = new CollectionClient($typeSenseClient); 251 | $container = $this->createMock(ContainerInterface::class); 252 | $transformer = new DoctrineToTypesenseTransformer($collectionDefinitions, $propertyAccessor, $container); 253 | $collectionManager = new CollectionManager($collectionClient, $transformer, $collectionDefinitions); 254 | 255 | $command = new CreateCommand($collectionManager); 256 | 257 | $application->add($command); 258 | 259 | return new CommandTester($application->find('typesense:create')); 260 | } 261 | 262 | private function importCommandTester($options): CommandTester 263 | { 264 | $application = new Application(); 265 | 266 | $application->setAutoExit(false); 267 | 268 | // Prepare all mocked objects required to run the command 269 | $books = $this->getMockedBooks($options); 270 | $collectionDefinitions = $this->getCollectionDefinitions(Book::class); 271 | $typeSenseClient = new TypesenseClient($_ENV['TYPESENSE_URL'], $_ENV['TYPESENSE_KEY']); 272 | $propertyAccessor = PropertyAccess::createPropertyAccessor(); 273 | $collectionClient = new CollectionClient($typeSenseClient); 274 | $container = $this->createMock(ContainerInterface::class); 275 | $transformer = new DoctrineToTypesenseTransformer($collectionDefinitions, $propertyAccessor, $container); 276 | $documentManager = new DocumentManager($typeSenseClient); 277 | $collectionManager = new CollectionManager($collectionClient, $transformer, $collectionDefinitions); 278 | $em = $this->getMockedEntityManager($books, $options); 279 | 280 | $command = new ImportCommand($em, $collectionManager, $documentManager, $transformer); 281 | 282 | $application->add($command); 283 | 284 | return new CommandTester($application->find('typesense:import')); 285 | } 286 | 287 | private function getCollectionDefinitions($entityClass) 288 | { 289 | return [ 290 | 'books' => [ 291 | 'typesense_name' => 'books', 292 | 'entity' => $entityClass, 293 | 'name' => 'books', 294 | 'default_sorting_field' => 'sortable_id', 295 | 'fields' => [ 296 | 'id' => [ 297 | 'name' => 'id', 298 | 'type' => 'primary', 299 | 'entity_attribute' => 'id', 300 | ], 301 | 'sortable_id' => [ 302 | 'entity_attribute' => 'id', 303 | 'name' => 'sortable_id', 304 | 'type' => 'int32', 305 | ], 306 | 'title' => [ 307 | 'name' => 'title', 308 | 'type' => 'string', 309 | 'entity_attribute' => 'title', 310 | ], 311 | 'author' => [ 312 | 'name' => 'author', 313 | 'type' => 'object', 314 | 'entity_attribute' => 'author', 315 | ], 316 | 'country' => [ 317 | 'name' => 'author_country', 318 | 'type' => 'string', 319 | 'entity_attribute' => 'author.country', 320 | ], 321 | 'publishedAt' => [ 322 | 'name' => 'published_at', 323 | 'type' => 'datetime', 324 | 'optional' => true, 325 | 'entity_attribute' => 'publishedAt', 326 | ], 327 | 'embeddings' => [ 328 | 'name' => 'embeddings', 329 | 'type' => 'float[]', 330 | 'optional' => false, 331 | "embed" => [ 332 | "from" => [ 333 | "author", 334 | "title" 335 | ], 336 | "model_config" => [ 337 | "model_name" => "ts/e5-small" 338 | ] 339 | ], 340 | ], 341 | ], 342 | ], 343 | ]; 344 | } 345 | 346 | private function getMockedBooks($options) 347 | { 348 | $author = new Author('Nicolas Potier', 'France'); 349 | $books = []; 350 | 351 | $nbBooks = $options['nbBooks'] ?? self::NB_BOOKS; 352 | for ($i = 0; $i < $nbBooks; ++$i) { 353 | $books[] = new Book($i, self::BOOK_TITLES[$i] ?? 'Book '.$i, $author, new \DateTime()); 354 | } 355 | 356 | return $books; 357 | } 358 | 359 | private function getMockedEntityManager($books, array $options = []) 360 | { 361 | $em = $this->createMock(EntityManager::class); 362 | 363 | $connection = $this->createMock(Connection::class); 364 | $em->method('getConnection')->willReturn($connection); 365 | 366 | $configuration = $this->createMock(Configuration::class); 367 | $connection->method('getConfiguration')->willReturn($configuration); 368 | 369 | $query = $this->createMock(Query::class); 370 | $em->method('createQuery')->willReturn($query); 371 | 372 | $query->method('getSingleScalarResult')->willReturn(count($books)); 373 | 374 | $query->method('setFirstResult')->willReturn($query); 375 | $query->method('setMaxResults')->willReturn($query); 376 | 377 | // Dirty Method to count number of call to the method toIterable in order to return 378 | // the good results 379 | $this->cptToIterableCall = isset($options['firstPage']) ? ($options['firstPage']-1) : 0; 380 | 381 | $maxPerPage = $options['maxPerPage'] ?? 100; 382 | 383 | $query->method('toIterable')->will($this->returnCallback(function() use ($books, $maxPerPage){ 384 | $result = array_slice($books, 385 | $this->cptToIterableCall * $maxPerPage, 386 | $maxPerPage 387 | ); 388 | $this->cptToIterableCall++; 389 | 390 | return $result; 391 | })); 392 | 393 | return $em; 394 | } 395 | 396 | /** 397 | * mock a lifeCycleEventArgs Object. 398 | * 399 | * @param $eventType 400 | */ 401 | private function getmockedEventCreate($book): \PHPUnit\Framework\MockObject\MockObject 402 | { 403 | $lifeCycleEvent = $this->createMock(LifecycleEventArgs::class); 404 | $lifeCycleEvent->method('getObject')->willReturn($book); 405 | 406 | return $lifeCycleEvent; 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /tests/Hook/BypassFinalHook.php: -------------------------------------------------------------------------------- 1 | registerExtension($extension = new ACSEOTypesenseExtension()); 34 | $containerBuilder->setParameter('kernel.debug', true); 35 | 36 | $loader = new YamlFileLoader($containerBuilder, new FileLocator(__DIR__.'/fixtures')); 37 | $loader->load('acseo_typesense.yml'); 38 | 39 | $extensionConfig = $containerBuilder->getExtensionConfig($extension->getAlias()); 40 | $extension->load($extensionConfig, $containerBuilder); 41 | 42 | $this->assertTrue($containerBuilder->hasDefinition('typesense.client')); 43 | 44 | $clientDefinition = $containerBuilder->findDefinition('typesense.client'); 45 | 46 | $this->assertSame('http://localhost:8108', $clientDefinition->getArgument(0)); 47 | $this->assertSame('ACSEO', $clientDefinition->getArgument(1)); 48 | } 49 | 50 | public function testFinderServiceDefinition() 51 | { 52 | $containerBuilder = new ContainerBuilder(); 53 | $containerBuilder->registerExtension($extension = new ACSEOTypesenseExtension()); 54 | $containerBuilder->setParameter('kernel.debug', true); 55 | 56 | $loader = new YamlFileLoader($containerBuilder, new FileLocator(__DIR__.'/fixtures')); 57 | $loader->load('acseo_typesense.yml'); 58 | 59 | $extensionConfig = $containerBuilder->getExtensionConfig($extension->getAlias()); 60 | $extension->load($extensionConfig, $containerBuilder); 61 | 62 | $this->assertTrue($containerBuilder->hasDefinition('typesense.finder')); 63 | $this->assertTrue($containerBuilder->hasDefinition('typesense.finder.books')); 64 | 65 | $finderBooksDefinition = $containerBuilder->findDefinition('typesense.finder.books'); 66 | $finderBooksDefinitionArguments = $finderBooksDefinition->getArguments(); 67 | $arguments = array_pop($finderBooksDefinitionArguments); 68 | 69 | $this->assertSame('books', $arguments['typesense_name']); 70 | $this->assertSame('books', $arguments['name']); 71 | } 72 | 73 | public function testFinderServiceDefinitionWithCollectionPrefix() 74 | { 75 | $containerBuilder = new ContainerBuilder(); 76 | $containerBuilder->registerExtension($extension = new ACSEOTypesenseExtension()); 77 | $containerBuilder->setParameter('kernel.debug', true); 78 | 79 | $loader = new YamlFileLoader($containerBuilder, new FileLocator(__DIR__.'/fixtures')); 80 | $loader->load('acseo_typesense_collection_prefix.yml'); 81 | 82 | $extensionConfig = $containerBuilder->getExtensionConfig($extension->getAlias()); 83 | $extension->load($extensionConfig, $containerBuilder); 84 | 85 | $this->assertTrue($containerBuilder->hasDefinition('typesense.finder')); 86 | $this->assertTrue($containerBuilder->hasDefinition('typesense.finder.books')); 87 | 88 | $finderBooksDefinition = $containerBuilder->findDefinition('typesense.finder.books'); 89 | $finderBooksDefinitionArguments = $finderBooksDefinition->getArguments(); 90 | $arguments = array_pop($finderBooksDefinitionArguments); 91 | 92 | $this->assertSame('acseo_prefix_books', $arguments['typesense_name']); 93 | $this->assertSame('books', $arguments['name']); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /tests/Unit/DependencyInjection/fixtures/acseo_typesense.yml: -------------------------------------------------------------------------------- 1 | acseo_typesense: 2 | typesense: 3 | url: 'http://localhost:8108' 4 | key: 'ACSEO' 5 | collections: 6 | # Collection settings 7 | books: # Typesense collection name 8 | entity: 'App\Entity\Book' # Doctrine Entity class 9 | collection_name: 'books' 10 | fields: 11 | # 12 | # Keeping Database and Typesense synchronized with ids 13 | # 14 | id: # Entity attribute name 15 | name: id # Typesense attribute name 16 | type: primary # Attribute type 17 | # 18 | # Using again id as a sortable field (int32 required) 19 | # 20 | sortable_id: 21 | entity_attribute: id # Entity attribute name forced 22 | name: sortable_id # Typesense field name 23 | type: int32 24 | title: 25 | name: title 26 | type: string 27 | author: 28 | name: author 29 | type: object # Object conversion with __toString() 30 | author.country: 31 | name: author_country 32 | type: string 33 | facet: true # Declare field as facet (required to use "group_by" query option) 34 | entity_attribute: author.country # Equivalent of $book->getAuthor()->getCountry() 35 | genres: 36 | name: genres 37 | type: collection # Convert ArrayCollection to array of strings 38 | publishedAt: 39 | name: publishedAt 40 | type: datetime 41 | optional: true # Declare field as optional 42 | default_sorting_field: sortable_id # Default sorting field. Must be int32 or float 43 | finders: 44 | title_or_author: 45 | finder_parameters: 46 | query_by : 'title,author' 47 | limit: 10 48 | num_typos: 2 49 | -------------------------------------------------------------------------------- /tests/Unit/DependencyInjection/fixtures/acseo_typesense_collection_prefix.yml: -------------------------------------------------------------------------------- 1 | acseo_typesense: 2 | typesense: 3 | url: 'http://localhost:8108' 4 | key: 'ACSEO' 5 | collection_prefix: 'acseo_prefix_' 6 | collections: 7 | # Collection settings 8 | books: # Typesense collection name 9 | entity: 'App\Entity\Book' # Doctrine Entity class 10 | collection_name: 'books' 11 | fields: 12 | # 13 | # Keeping Database and Typesense synchronized with ids 14 | # 15 | id: # Entity attribute name 16 | name: id # Typesense attribute name 17 | type: primary # Attribute type 18 | # 19 | # Using again id as a sortable field (int32 required) 20 | # 21 | sortable_id: 22 | entity_attribute: id # Entity attribute name forced 23 | name: sortable_id # Typesense field name 24 | type: int32 25 | title: 26 | name: title 27 | type: string 28 | author: 29 | name: author 30 | type: object # Object conversion with __toString() 31 | author.country: 32 | name: author_country 33 | type: string 34 | facet: true # Declare field as facet (required to use "group_by" query option) 35 | entity_attribute: author.country # Equivalent of $book->getAuthor()->getCountry() 36 | genres: 37 | name: genres 38 | type: collection # Convert ArrayCollection to array of strings 39 | publishedAt: 40 | name: publishedAt 41 | type: datetime 42 | optional: true # Declare field as optional 43 | default_sorting_field: sortable_id # Default sorting field. Must be int32 or float 44 | finders: 45 | title_or_author: 46 | finder_parameters: 47 | query_by : 'title,author' 48 | limit: 10 49 | num_typos: 2 50 | -------------------------------------------------------------------------------- /tests/Unit/EventListener/TypesenseIndexerTest.php: -------------------------------------------------------------------------------- 1 | objectManager = $this->prophesize(ObjectManager::class); 34 | $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); 35 | $this->container = $this->prophesize(ContainerInterface::class); 36 | } 37 | 38 | private function initialize($collectionDefinitions) 39 | { 40 | $transformer = new DoctrineToTypesenseTransformer($collectionDefinitions, $this->propertyAccessor, $this->container->reveal()); 41 | 42 | $collectionClient = $this->prophesize(CollectionClient::class); 43 | 44 | $collectionManager = new CollectionManager($collectionClient->reveal(), $transformer, $collectionDefinitions); 45 | $this->documentManager = $this->prophesize(DocumentManager::class); 46 | 47 | $this->eventListener = new TypesenseIndexer($collectionManager, $this->documentManager->reveal(), $transformer); 48 | } 49 | 50 | /** 51 | * @dataProvider postUpdateProvider 52 | */ 53 | public function testPostUpdate($prefix) 54 | { 55 | $collectionDefinitions = $this->getCollectionDefinitions(Book::class, $prefix); 56 | 57 | $this->initialize($collectionDefinitions); 58 | 59 | $book = new Book(1, 'The Doors of Perception', new Author('Aldoux Huxley', 'United Kingdom'), new \DateTime('01/01/1984 00:00:00')); 60 | 61 | $eventArgs = new LifecycleEventArgs($book, $this->objectManager->reveal()); 62 | 63 | $this->eventListener->postUpdate($eventArgs); 64 | $this->eventListener->postFlush(); 65 | 66 | $this->documentManager->index(sprintf('%sbooks', $prefix), [ 67 | 'id' => 1, 68 | 'sortable_id' => 1, 69 | 'title' => 'The Doors of Perception', 70 | 'author' => 'Aldoux Huxley', 71 | 'author_country' => 'United Kingdom', 72 | 'published_at' => 441763200, 73 | 'active' => false 74 | ])->shouldHaveBeenCalled(); 75 | } 76 | 77 | public function postUpdateProvider() 78 | { 79 | return [ 80 | [ '' ], 81 | [ 'foo_' ], 82 | ]; 83 | } 84 | 85 | private function getCollectionDefinitions($entityClass, $prefix = '') 86 | { 87 | return [ 88 | 'books' => [ 89 | 'typesense_name' => sprintf('%sbooks', $prefix), 90 | 'entity' => $entityClass, 91 | 'name' => 'books', 92 | 'fields' => [ 93 | 'id' => [ 94 | 'name' => 'id', 95 | 'type' => 'primary', 96 | 'entity_attribute' => 'id', 97 | ], 98 | 'sortable_id' => [ 99 | 'entity_attribute' => 'id', 100 | 'name' => 'sortable_id', 101 | 'type' => 'int32', 102 | ], 103 | 'title' => [ 104 | 'name' => 'title', 105 | 'type' => 'string', 106 | 'entity_attribute' => 'title', 107 | ], 108 | 'author' => [ 109 | 'name' => 'author', 110 | 'type' => 'object', 111 | 'entity_attribute' => 'author', 112 | ], 113 | 'michel' => [ 114 | 'name' => 'author_country', 115 | 'type' => 'string', 116 | 'entity_attribute' => 'author.country', 117 | ], 118 | 'publishedAt' => [ 119 | 'name' => 'published_at', 120 | 'type' => 'datetime', 121 | 'optional' => true, 122 | 'entity_attribute' => 'publishedAt', 123 | ], 124 | 'active' => [ 125 | 'name' => 'active', 126 | 'type' => 'bool', 127 | 'entity_attribute' => 'active', 128 | ] 129 | ], 130 | 'default_sorting_field' => 'sortable_id', 131 | ], 132 | ]; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /tests/Unit/Finder/TypesenseQueryTest.php: -------------------------------------------------------------------------------- 1 | 'search term', 'query_by' => 'search field'], 18 | $query->getParameters() 19 | ); 20 | } 21 | 22 | public function testComplexQuery() 23 | { 24 | $query = (new TypesenseQuery('search term', 'search field')) 25 | ->prefix(false) 26 | ->filterBy('filter term') 27 | ->sortBy('sort term') 28 | ->infix('off') 29 | ->facetBy('facet term') 30 | ->maxFacetValues(10) 31 | ->numTypos(0) 32 | ->page(1) 33 | ->perPage(20) 34 | ->includeFields('field1,field2') 35 | ->excludeFields('field3,field4') 36 | ->dropTokensThreshold(0) 37 | ; 38 | 39 | self::assertEquals( 40 | [ 41 | 'q' => 'search term', 42 | 'query_by' => 'search field', 43 | 'prefix' => false, 44 | 'filter_by' => 'filter term', 45 | 'sort_by' => 'sort term', 46 | 'infix' => 'off', 47 | 'facet_by' => 'facet term', 48 | 'max_facet_values' => 10, 49 | 'num_typos' => 0, 50 | 'page' => 1, 51 | 'per_page' => 20, 52 | 'include_fields' => 'field1,field2', 53 | 'exclude_fields' => 'field3,field4', 54 | 'drop_tokens_threshold' => 0, 55 | ], 56 | $query->getParameters() 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/Unit/Finder/TypesenseResponseTest.php: -------------------------------------------------------------------------------- 1 | constructResponse(); 27 | } 28 | 29 | public function testGetters() 30 | { 31 | $response = $this->constructResponse(); 32 | 33 | self::assertEquals(0, $response->getFacetCounts()); 34 | self::assertEquals([], $response->getRawResults()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Unit/Transformer/DoctrineToTypesenseTransformerTest.php: -------------------------------------------------------------------------------- 1 | getCollectionDefinitions(Book::class); 27 | $propertyAccessor = PropertyAccess::createPropertyAccessor(); 28 | $container = $this->getContainerInstance(); 29 | $transformer = new DoctrineToTypesenseTransformer($collectionDefinitions, $propertyAccessor, $container); 30 | 31 | self::assertEquals($expectedResult, $transformer->convert($book)); 32 | } 33 | 34 | public function bookData() 35 | { 36 | return [ 37 | [ 38 | new Book(1, 'test', null, new \Datetime('01/01/1984 00:00:00')), 39 | [ 40 | "id" => "1", 41 | "sortable_id" => 1, 42 | "title" => "test", 43 | "author" => null, 44 | "author_country" => null, 45 | "published_at" => 441763200, 46 | "active" => false, 47 | "cover_image_url" => "http://fake.image/1" 48 | ] 49 | ], 50 | [ 51 | new Book(1, 'test', new Author('Nicolas Potier', 'France'), new \DateTimeImmutable('01/01/1984 00:00:00')), 52 | [ 53 | "id" => "1", 54 | "sortable_id" => 1, 55 | "title" => "test", 56 | "author" => "Nicolas Potier", 57 | "author_country" => "France", 58 | "published_at" => 441763200, 59 | "active" => false, 60 | "cover_image_url" => "http://fake.image/1" 61 | ] 62 | ], 63 | [ 64 | new Book(1, 'test', new Author('Nicolas Potier', 'France'), new \DateTimeImmutable('01/01/1984 00:00:00'), 'this string will return true'), 65 | [ 66 | "id" => "1", 67 | "sortable_id" => 1, 68 | "title" => "test", 69 | "author" => "Nicolas Potier", 70 | "author_country" => "France", 71 | "published_at" => 441763200, 72 | "active" => true, 73 | "cover_image_url" => "http://fake.image/1" 74 | ] 75 | ], 76 | [ 77 | new Book(1, 'test', new Author('Nicolas Potier', 'France'), new \DateTimeImmutable('01/01/1984 00:00:00'), '0'), 78 | [ 79 | "id" => "1", 80 | "sortable_id" => 1, 81 | "title" => "test", 82 | "author" => "Nicolas Potier", 83 | "author_country" => "France", 84 | "published_at" => 441763200, 85 | "active" => false, 86 | "cover_image_url" => "http://fake.image/1" 87 | ] 88 | ] 89 | 90 | ]; 91 | } 92 | 93 | public function testChildConvert() 94 | { 95 | $collectionDefinitions = $this->getCollectionDefinitions(Book::class); 96 | $propertyAccessor = PropertyAccess::createPropertyAccessor(); 97 | $container = $this->getContainerInstance(); 98 | $transformer = new DoctrineToTypesenseTransformer($collectionDefinitions, $propertyAccessor, $container); 99 | 100 | $book = new BookOnline(1, 'test', new Author('Nicolas Potier', 'France'), new \Datetime('01/01/1984 00:00:00')); 101 | $book->setUrl('https://www.acseo.fr'); 102 | 103 | self::assertEquals( 104 | [ 105 | "id" => "1", 106 | "sortable_id" => 1, 107 | "title" => "test", 108 | "author" => "Nicolas Potier", 109 | "author_country" => "France", 110 | "published_at" => 441763200, 111 | "cover_image_url" => "http://fake.image/1", 112 | 'active' => false 113 | ], 114 | $transformer->convert($book) 115 | ); 116 | 117 | $book = new Book(1, 'test', new Author('Nicolas Potier', 'France'), new \DateTimeImmutable('01/01/1984 00:00:00')); 118 | 119 | self::assertEquals( 120 | [ 121 | "id" => "1", 122 | "sortable_id" => 1, 123 | "title" => "test", 124 | "author" => "Nicolas Potier", 125 | "author_country" => "France", 126 | "cover_image_url" => "http://fake.image/1", 127 | "published_at" => 441763200, 128 | 'active' => false 129 | ], 130 | $transformer->convert($book) 131 | ); 132 | } 133 | 134 | public function testCastValueDatetime() 135 | { 136 | $collectionDefinitions = $this->getCollectionDefinitions(Book::class); 137 | $propertyAccessor = PropertyAccess::createPropertyAccessor(); 138 | $container = $this->getContainerInstance(); 139 | $transformer = new DoctrineToTypesenseTransformer($collectionDefinitions, $propertyAccessor, $container); 140 | //Datetime 141 | $value = $transformer->castValue(Book::class, 'published_at', new \Datetime('01/01/1984 00:00:00')); 142 | self::assertEquals(441763200, $value); 143 | //DatetimeImmutable 144 | $value = $transformer->castValue(Book::class, 'published_at', new \DatetimeImmutable('01/01/1984 00:00:00')); 145 | self::assertEquals(441763200, $value); 146 | } 147 | 148 | public function testCastValueObject() 149 | { 150 | $collectionDefinitions = $this->getCollectionDefinitions(Book::class); 151 | $propertyAccessor = PropertyAccess::createPropertyAccessor(); 152 | $container = $this->getContainerInstance(); 153 | $transformer = new DoctrineToTypesenseTransformer($collectionDefinitions, $propertyAccessor, $container); 154 | 155 | // Conversion OK 156 | $author = new Author('Nicolas Potier', 'France'); 157 | $value = $transformer->castValue(Book::class, 'author', $author); 158 | self::assertEquals($author->__toString(), $value); 159 | 160 | // Conversion KO 161 | $this->expectExceptionMessage('Call to undefined method ArrayObject::__toString()'); 162 | $value = $transformer->castValue(Book::class, 'author', new \ArrayObject()); 163 | } 164 | 165 | 166 | public function testCastValueViaService() 167 | { 168 | $collectionDefinitions = $this->getCollectionDefinitions(Book::class); 169 | $propertyAccessor = PropertyAccess::createPropertyAccessor(); 170 | $container = $this->getContainerInstance(); 171 | $transformer = new DoctrineToTypesenseTransformer($collectionDefinitions, $propertyAccessor, $container); 172 | 173 | $book = new Book(1, 'test', new Author('Nicolas Potier', 'France'), new \Datetime('01/01/1984 00:00:00')); 174 | $expectedResult = [ 175 | "id" => "1", 176 | "sortable_id" => 1, 177 | "title" => "test", 178 | "author" => "Nicolas Potier", 179 | "author_country" => "France", 180 | "published_at" => 441763200, 181 | "active" => false, 182 | "cover_image_url" => "http://fake.image/1" 183 | ]; 184 | self::assertEquals($expectedResult, $transformer->convert($book)); 185 | 186 | // Override collectionDefinition to declare a wrong service converter 187 | $collectionDefinitions['books']['fields']['cover_image_url']['entity_attribute'] = 'This service does not exists'; 188 | $transformer = new DoctrineToTypesenseTransformer($collectionDefinitions, $propertyAccessor, $container); 189 | $expectedResult = [ 190 | "id" => "1", 191 | "sortable_id" => 1, 192 | "title" => "test", 193 | "author" => "Nicolas Potier", 194 | "author_country" => "France", 195 | "published_at" => 441763200, 196 | "active" => false, 197 | "cover_image_url" => null 198 | ]; 199 | 200 | self::assertEquals($expectedResult, $transformer->convert($book)); 201 | 202 | // Override collectionDefinition to declare a service that throws exception 203 | $collectionDefinitions['books']['fields']['cover_image_url']['entity_attribute'] = 'ACSEO\TypesenseBundle\Tests\Functional\Service\ExceptionBookConverter::getCoverImageURL'; 204 | $transformer = new DoctrineToTypesenseTransformer($collectionDefinitions, $propertyAccessor, $container); 205 | $this->expectExceptionMessage("I'm trowing an exception during conversion"); 206 | $result = $transformer->convert($book); 207 | 208 | // Override collectionDefinition to declare a service that throws exception 209 | $collectionDefinitions['books']['fields']['cover_image_url']['entity_attribute'] = 'ACSEO\TypesenseBundle\Tests\Functional\Service\BookConverter::thisMethodDoesNotExists'; 210 | $transformer = new DoctrineToTypesenseTransformer($collectionDefinitions, $propertyAccessor, $container); 211 | self::assertEquals($expectedResult, $transformer->convert($book)); 212 | } 213 | 214 | private function getCollectionDefinitions($entityClass) 215 | { 216 | return [ 217 | 'books' => [ 218 | 'typesense_name' => 'books', 219 | 'entity' => $entityClass, 220 | 'name' => 'books', 221 | 'fields' => [ 222 | 'id' => [ 223 | 'name' => 'id', 224 | 'type' => 'primary', 225 | 'entity_attribute' => 'id', 226 | ], 227 | 'sortable_id' => [ 228 | 'entity_attribute' => 'id', 229 | 'name' => 'sortable_id', 230 | 'type' => 'int32', 231 | ], 232 | 'title' => [ 233 | 'name' => 'title', 234 | 'type' => 'string', 235 | 'entity_attribute' => 'title', 236 | ], 237 | 'author' => [ 238 | 'name' => 'author', 239 | 'type' => 'object', 240 | 'entity_attribute' => 'author', 241 | 'optional' => true 242 | ], 243 | 'michel' => [ 244 | 'name' => 'author_country', 245 | 'type' => 'string', 246 | 'entity_attribute' => 'author.country', 247 | ], 248 | 'active' => [ 249 | 'name' => 'active', 250 | 'type' => 'bool', 251 | 'entity_attribute' => 'active' 252 | ], 253 | 'publishedAt' => [ 254 | 'name' => 'published_at', 255 | 'type' => 'datetime', 256 | 'optional' => true, 257 | 'entity_attribute' => 'publishedAt', 258 | ], 259 | 'cover_image_url' => [ 260 | 'name' => 'cover_image_url', 261 | 'type' => 'string', 262 | 'optional' => true, 263 | 'entity_attribute' => 'ACSEO\TypesenseBundle\Tests\Functional\Service\BookConverter::getCoverImageURL', 264 | ] 265 | ], 266 | 'default_sorting_field' => 'sortable_id', 267 | ], 268 | ]; 269 | } 270 | 271 | private function getContainerInstance() 272 | { 273 | $containerInstance = new Container(); 274 | $containerInstance->set('ACSEO\TypesenseBundle\Tests\Functional\Service\BookConverter', new BookConverter()); 275 | $containerInstance->set('ACSEO\TypesenseBundle\Tests\Functional\Service\ExceptionBookConverter', new ExceptionBookConverter()); 276 | return $containerInstance; 277 | } 278 | } --------------------------------------------------------------------------------