├── .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 | }
--------------------------------------------------------------------------------