├── .github
└── FUNDING.yml
├── LICENSE
├── README.md
├── composer.json
├── phpstan.dist.neon
└── src
├── Adapter
├── AdapterFactory.php
├── AdapterFactoryInterface.php
├── AdapterInterface.php
├── BulkHelper.php
├── IndexerInterface.php
├── SchemaManagerInterface.php
└── SearcherInterface.php
├── Engine.php
├── EngineInterface.php
├── EngineRegistry.php
├── Exception
└── DocumentNotFoundException.php
├── Marshaller
├── FlattenMarshaller.php
├── Flattener.php
└── Marshaller.php
├── Reindex
├── ReindexConfig.php
└── ReindexProviderInterface.php
├── Schema
├── Exception
│ └── FieldByPathNotFoundException.php
├── Field
│ ├── AbstractField.php
│ ├── BooleanField.php
│ ├── DateTimeField.php
│ ├── FloatField.php
│ ├── GeoPointField.php
│ ├── IdentifierField.php
│ ├── IntegerField.php
│ ├── ObjectField.php
│ ├── TextField.php
│ └── TypedField.php
├── Index.php
├── Loader
│ ├── LoaderInterface.php
│ └── PhpFileLoader.php
└── Schema.php
├── Search
├── Condition
│ ├── AbstractGroupCondition.php
│ ├── AndCondition.php
│ ├── EqualCondition.php
│ ├── GeoBoundingBoxCondition.php
│ ├── GeoDistanceCondition.php
│ ├── GreaterThanCondition.php
│ ├── GreaterThanEqualCondition.php
│ ├── IdentifierCondition.php
│ ├── InCondition.php
│ ├── LessThanCondition.php
│ ├── LessThanEqualCondition.php
│ ├── NotEqualCondition.php
│ ├── NotInCondition.php
│ ├── OrCondition.php
│ └── SearchCondition.php
├── Result.php
├── Search.php
└── SearchBuilder.php
├── Task
├── AsyncTask.php
├── MultiTask.php
├── SyncTask.php
└── TaskInterface.php
└── Testing
├── AbstractAdapterTestCase.php
├── AbstractIndexerTestCase.php
├── AbstractSchemaManagerTestCase.php
├── AbstractSearcherTestCase.php
├── TaskHelper.php
└── TestingHelper.php
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [alexander-schranz]
2 | custom: ["https://paypal.me/L91"]
3 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Alexander Schranz
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 | > **Note**:
2 | > This is part of the `cmsig/search` project create issues in the [main repository](https://github.com/php-cmsig/search).
3 |
4 | ---
5 |
6 |
7 |

8 |
9 |
10 |
11 |
12 | SEAL Core
13 |
14 |
15 |
16 | **S**earch **E**ngine **A**bstraction **L**ayer with support to different search engines.
17 |
18 |
19 |
20 |
21 |
22 |
23 | This package was highly inspired by [Doctrine DBAL](https://github.com/doctrine/dbal)
24 | and [Flysystem](https://github.com/thephpleague/flysystem).
25 |
26 | > **Note**:
27 | > This project is heavily under development and any feedback is greatly appreciated.
28 |
29 | ## Installation & Usage
30 |
31 | The documentation is available at [https://php-cmsig.github.io/search/](https://php-cmsig.github.io/search/).
32 | It is the recommended and best way to start using the library, it will step-by-step guide you through all the features
33 | of the library.
34 |
35 | - [Introduction](https://php-cmsig.github.io/search/index.html)
36 | - [Getting Started](https://php-cmsig.github.io/search/getting-started/index.html)
37 | - [Schema](https://php-cmsig.github.io/search/schema/index.html)
38 | - [Index Operations](https://php-cmsig.github.io/search/indexing/index.html)
39 | - [Search & Filters](https://php-cmsig.github.io/search/search-and-filters/index.html)
40 | - [Cookbooks](https://php-cmsig.github.io/search/cookbooks/index.html)
41 | - [Research](https://php-cmsig.github.io/search/research/index.html)
42 |
43 | ## Authors
44 |
45 | - [Alexander Schranz](https://github.com/alexander-schranz/)
46 | - [The Community Contributors](https://github.com/php-cmsig/search/graphs/contributors)
47 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cmsig/seal",
3 | "description": "Search Engine Abstraction Layer",
4 | "type": "library",
5 | "license": "MIT",
6 | "keywords": [
7 | "abstraction",
8 | "search",
9 | "search-client",
10 | "search-abstraction",
11 | "cmsig",
12 | "seal",
13 | "elasticsearch",
14 | "opensearch",
15 | "meilisearch",
16 | "typesense",
17 | "solr",
18 | "redisearch",
19 | "algolia"
20 | ],
21 | "autoload": {
22 | "psr-4": {
23 | "CmsIg\\Seal\\": "src"
24 | }
25 | },
26 | "autoload-dev": {
27 | "psr-4": {
28 | "CmsIg\\Seal\\Tests\\": "tests"
29 | }
30 | },
31 | "authors": [
32 | {
33 | "name": "Alexander Schranz",
34 | "email": "alexander@sulu.io"
35 | }
36 | ],
37 | "require": {
38 | "php": "^8.1"
39 | },
40 | "require-dev": {
41 | "php-cs-fixer/shim": "^3.51",
42 | "phpstan/extension-installer": "^1.2",
43 | "phpstan/phpstan": "^1.10",
44 | "phpstan/phpstan-phpunit": "^1.3",
45 | "phpunit/phpunit": "^10.3",
46 | "rector/rector": "^1.0"
47 | },
48 | "replace": {
49 | "schranz-search/seal": "self.version"
50 | },
51 | "scripts": {
52 | "test": [
53 | "Composer\\Config::disableProcessTimeout",
54 | "vendor/bin/phpunit"
55 | ],
56 | "phpstan": "@php vendor/bin/phpstan analyze",
57 | "lint-rector": "@php vendor/bin/rector process --dry-run",
58 | "lint-php-cs": "@php vendor/bin/php-cs-fixer fix --verbose --diff --dry-run",
59 | "lint": [
60 | "@phpstan",
61 | "@lint-php-cs",
62 | "@lint-rector",
63 | "@lint-composer"
64 | ],
65 | "lint-composer": "@composer validate --strict",
66 | "rector": "@php vendor/bin/rector process",
67 | "php-cs-fix": "@php vendor/bin/php-cs-fixer fix",
68 | "fix": [
69 | "@rector",
70 | "@php-cs-fix"
71 | ]
72 | },
73 | "minimum-stability": "dev",
74 | "config": {
75 | "allow-plugins": {
76 | "phpstan/extension-installer": true
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/phpstan.dist.neon:
--------------------------------------------------------------------------------
1 | includes:
2 | - ../../phpstan.dist.neon
3 |
4 | parameters:
5 | paths:
6 | - .
7 | excludePaths:
8 | - vendor/*
9 |
--------------------------------------------------------------------------------
/src/Adapter/AdapterFactory.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Adapter;
15 |
16 | /**
17 | * @experimental
18 | */
19 | final class AdapterFactory
20 | {
21 | /**
22 | * @var array
23 | */
24 | private array $factories;
25 |
26 | /**
27 | * @param iterable $factories
28 | */
29 | public function __construct(
30 | iterable $factories,
31 | ) {
32 | $this->factories = [...$factories];
33 | }
34 |
35 | /**
36 | * @internal
37 | *
38 | * @return array{
39 | * scheme: string,
40 | * host: string,
41 | * port?: int,
42 | * user?: string,
43 | * pass?: string,
44 | * path?: string,
45 | * query: array,
46 | * fragment?: string,
47 | * }
48 | */
49 | public function parseDsn(string $dsn): array
50 | {
51 | /** @var string|null $adapterName */
52 | $adapterName = \explode(':', $dsn, 2)[0];
53 |
54 | if (!$adapterName) {
55 | throw new \InvalidArgumentException(
56 | 'Invalid DSN: "' . $dsn . '".',
57 | );
58 | }
59 |
60 | if (!isset($this->factories[$adapterName])) {
61 | throw new \InvalidArgumentException(
62 | 'Unknown Search adapter: "' . $adapterName . '" available adapters are "' . \implode('", "', \array_keys($this->factories)) . '".',
63 | );
64 | }
65 |
66 | /**
67 | * @var array{
68 | * scheme: string,
69 | * host: string,
70 | * port?: int,
71 | * user?: string,
72 | * pass?: string,
73 | * path?: string,
74 | * query?: string,
75 | * fragment?: string,
76 | * }|false $parsedDsn
77 | */
78 | $parsedDsn = \parse_url($dsn);
79 |
80 | // make DSN like algolia://YourApplicationID:YourAdminAPIKey parseable
81 | if (false === $parsedDsn) {
82 | $query = '';
83 | if (\str_contains($dsn, '?')) {
84 | [$dsn, $query] = \explode('?', $dsn);
85 | $query = '?' . $query;
86 | }
87 |
88 | $isWindowsLikePath = false;
89 | if (\str_contains($dsn, ':///')) {
90 | // make DSN like loupe:///full/path/project/var/indexes parseable
91 | $dsn = \str_replace(':///', '://' . $adapterName . '/', $dsn . $query);
92 | } elseif (':\\' === \substr($dsn, \strlen($adapterName) + 4, 2)) { // detect windows like path
93 | // make the DSN work with parseurl for URLs like loupe://C:\path\project\var\indexes
94 | $dsn = $adapterName . '://' . $adapterName . '/' . \rawurlencode(\substr($dsn, \strlen($adapterName) + 3)) . $query;
95 | $isWindowsLikePath = true;
96 | } else {
97 | $dsn = $dsn . '@' . $adapterName . $query;
98 | }
99 |
100 | /**
101 | * @var array{
102 | * scheme: string,
103 | * host: string,
104 | * port?: int,
105 | * user?: string,
106 | * pass?: string,
107 | * path?: string,
108 | * query?: string,
109 | * fragment?: string,
110 | * } $parsedDsn
111 | */
112 | $parsedDsn = \parse_url($dsn);
113 |
114 | if ($isWindowsLikePath && isset($parsedDsn['path'])) {
115 | $parsedDsn['path'] = \rawurldecode(\ltrim($parsedDsn['path'], '/'));
116 | }
117 | $parsedDsn['host'] = '';
118 | }
119 |
120 | /** @var array $query */
121 | $query = [];
122 | if (isset($parsedDsn['query'])) {
123 | \parse_str($parsedDsn['query'], $query);
124 | }
125 |
126 | $parsedDsn['query'] = $query;
127 |
128 | /**
129 | * @var array{
130 | * scheme: string,
131 | * host: string,
132 | * port?: int,
133 | * user?: string,
134 | * pass?: string,
135 | * path?: string,
136 | * query: array,
137 | * fragment?: string,
138 | * } $parsedDsn
139 | */
140 |
141 | return $parsedDsn;
142 | }
143 |
144 | public function createAdapter(string $dsn): AdapterInterface
145 | {
146 | $parsedDsn = $this->parseDsn($dsn);
147 |
148 | return $this->factories[$parsedDsn['scheme']]->createAdapter($parsedDsn);
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/src/Adapter/AdapterFactoryInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Adapter;
15 |
16 | /**
17 | * @experimental
18 | */
19 | interface AdapterFactoryInterface
20 | {
21 | /**
22 | * @param array{
23 | * scheme: string,
24 | * host: string,
25 | * port?: int,
26 | * user?: string,
27 | * pass?: string,
28 | * path?: string,
29 | * query: array,
30 | * fragment?: string,
31 | * } $dsn
32 | */
33 | public function createAdapter(array $dsn): AdapterInterface;
34 |
35 | /**
36 | * Returns the expected DSN scheme for this adapter.
37 | */
38 | public static function getName(): string;
39 | }
40 |
--------------------------------------------------------------------------------
/src/Adapter/AdapterInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Adapter;
15 |
16 | interface AdapterInterface
17 | {
18 | public function getSchemaManager(): SchemaManagerInterface;
19 |
20 | public function getIndexer(): IndexerInterface;
21 |
22 | public function getSearcher(): SearcherInterface;
23 | }
24 |
--------------------------------------------------------------------------------
/src/Adapter/BulkHelper.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Adapter;
15 |
16 | /**
17 | * @internal
18 | */
19 | final class BulkHelper
20 | {
21 | /**
22 | * @template T of mixed
23 | *
24 | * @param iterable $iterables
25 | *
26 | * @return \Generator
27 | */
28 | public static function splitBulk(iterable $iterables, int $bulkSize): \Generator
29 | {
30 | $bulk = [];
31 | $count = 0;
32 |
33 | foreach ($iterables as $iterable) {
34 | $bulk[] = $iterable;
35 | ++$count;
36 |
37 | if (0 === ($count % $bulkSize)) {
38 | yield $bulk;
39 | $bulk = [];
40 | }
41 | }
42 |
43 | if ([] !== $bulk) {
44 | yield $bulk;
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/Adapter/IndexerInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Adapter;
15 |
16 | use CmsIg\Seal\Schema\Index;
17 | use CmsIg\Seal\Task\TaskInterface;
18 |
19 | interface IndexerInterface
20 | {
21 | /**
22 | * @param array $document
23 | * @param array{return_slow_promise_result?: true} $options
24 | *
25 | * @return ($options is non-empty-array ? TaskInterface> : null)
26 | */
27 | public function save(Index $index, array $document, array $options = []): TaskInterface|null;
28 |
29 | /**
30 | * @param array{return_slow_promise_result?: true} $options
31 | *
32 | * @return ($options is non-empty-array ? TaskInterface : null)
33 | */
34 | public function delete(Index $index, string $identifier, array $options = []): TaskInterface|null;
35 |
36 | /**
37 | * @param iterable> $saveDocuments
38 | * @param iterable $deleteDocumentIdentifiers
39 | * @param array{return_slow_promise_result?: true} $options
40 | *
41 | * @return ($options is non-empty-array ? TaskInterface : null)
42 | */
43 | public function bulk(
44 | Index $index,
45 | iterable $saveDocuments,
46 | iterable $deleteDocumentIdentifiers,
47 | int $bulkSize = 100,
48 | array $options = [],
49 | ): TaskInterface|null;
50 | }
51 |
--------------------------------------------------------------------------------
/src/Adapter/SchemaManagerInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Adapter;
15 |
16 | use CmsIg\Seal\Schema\Index;
17 | use CmsIg\Seal\Task\TaskInterface;
18 |
19 | interface SchemaManagerInterface
20 | {
21 | public function existIndex(Index $index): bool;
22 |
23 | /**
24 | * @template T of bool
25 | *
26 | * @param array{return_slow_promise_result?: T} $options
27 | *
28 | * @return (T is true ? TaskInterface : null)
29 | */
30 | public function dropIndex(Index $index, array $options = []): TaskInterface|null;
31 |
32 | /**
33 | * @template T of bool
34 | *
35 | * @param array{return_slow_promise_result?: T} $options
36 | *
37 | * @return (T is true ? TaskInterface : null)
38 | */
39 | public function createIndex(Index $index, array $options = []): TaskInterface|null;
40 | }
41 |
--------------------------------------------------------------------------------
/src/Adapter/SearcherInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Adapter;
15 |
16 | use CmsIg\Seal\Search\Result;
17 | use CmsIg\Seal\Search\Search;
18 |
19 | interface SearcherInterface
20 | {
21 | public function search(Search $search): Result;
22 | }
23 |
--------------------------------------------------------------------------------
/src/Engine.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal;
15 |
16 | use CmsIg\Seal\Adapter\AdapterInterface;
17 | use CmsIg\Seal\Exception\DocumentNotFoundException;
18 | use CmsIg\Seal\Reindex\ReindexConfig;
19 | use CmsIg\Seal\Reindex\ReindexProviderInterface;
20 | use CmsIg\Seal\Schema\Schema;
21 | use CmsIg\Seal\Search\Condition\IdentifierCondition;
22 | use CmsIg\Seal\Search\SearchBuilder;
23 | use CmsIg\Seal\Task\MultiTask;
24 | use CmsIg\Seal\Task\TaskInterface;
25 |
26 | final class Engine implements EngineInterface
27 | {
28 | public function __construct(
29 | readonly private AdapterInterface $adapter,
30 | readonly private Schema $schema,
31 | ) {
32 | }
33 |
34 | public function saveDocument(string $index, array $document, array $options = []): TaskInterface|null
35 | {
36 | return $this->adapter->getIndexer()->save(
37 | $this->schema->indexes[$index],
38 | $document,
39 | $options,
40 | );
41 | }
42 |
43 | public function deleteDocument(string $index, string $identifier, array $options = []): TaskInterface|null
44 | {
45 | return $this->adapter->getIndexer()->delete(
46 | $this->schema->indexes[$index],
47 | $identifier,
48 | $options,
49 | );
50 | }
51 |
52 | public function bulk(string $index, iterable $saveDocuments, iterable $deleteDocumentIdentifiers, int $bulkSize = 100, array $options = []): TaskInterface|null
53 | {
54 | return $this->adapter->getIndexer()->bulk(
55 | $this->schema->indexes[$index],
56 | $saveDocuments,
57 | $deleteDocumentIdentifiers,
58 | $bulkSize,
59 | $options,
60 | );
61 | }
62 |
63 | public function getDocument(string $index, string $identifier): array
64 | {
65 | $documents = [...$this->createSearchBuilder($index)
66 | ->addFilter(new IdentifierCondition($identifier))
67 | ->limit(1)
68 | ->getResult()];
69 |
70 | /** @var array|null $document */
71 | $document = $documents[0] ?? null;
72 |
73 | if (null === $document) {
74 | throw new DocumentNotFoundException(\sprintf(
75 | 'Document with the identifier "%s" not found in index "%s".',
76 | $identifier,
77 | $index,
78 | ));
79 | }
80 |
81 | return $document;
82 | }
83 |
84 | public function createSearchBuilder(string $index): SearchBuilder
85 | {
86 | return (new SearchBuilder(
87 | $this->schema,
88 | $this->adapter->getSearcher(),
89 | ))
90 | ->index($index);
91 | }
92 |
93 | public function createIndex(string $index, array $options = []): TaskInterface|null
94 | {
95 | return $this->adapter->getSchemaManager()->createIndex($this->schema->indexes[$index], $options);
96 | }
97 |
98 | public function dropIndex(string $index, array $options = []): TaskInterface|null
99 | {
100 | return $this->adapter->getSchemaManager()->dropIndex($this->schema->indexes[$index], $options);
101 | }
102 |
103 | public function existIndex(string $index): bool
104 | {
105 | return $this->adapter->getSchemaManager()->existIndex($this->schema->indexes[$index]);
106 | }
107 |
108 | public function createSchema(array $options = []): TaskInterface|null
109 | {
110 | $tasks = [];
111 | foreach ($this->schema->indexes as $index) {
112 | $tasks[] = $this->adapter->getSchemaManager()->createIndex($index, $options);
113 | }
114 |
115 | if (!($options['return_slow_promise_result'] ?? false)) {
116 | return null;
117 | }
118 |
119 | return new MultiTask($tasks); // @phpstan-ignore-line
120 | }
121 |
122 | public function dropSchema(array $options = []): TaskInterface|null
123 | {
124 | $tasks = [];
125 | foreach ($this->schema->indexes as $index) {
126 | $tasks[] = $this->adapter->getSchemaManager()->dropIndex($index, $options);
127 | }
128 |
129 | if (!($options['return_slow_promise_result'] ?? false)) {
130 | return null;
131 | }
132 |
133 | return new MultiTask($tasks); // @phpstan-ignore-line
134 | }
135 |
136 | public function reindex(
137 | iterable $reindexProviders,
138 | ReindexConfig $reindexConfig,
139 | callable|null $progressCallback = null,
140 | ): void {
141 | /** @var array $reindexProvidersPerIndex */
142 | $reindexProvidersPerIndex = [];
143 | foreach ($reindexProviders as $reindexProvider) {
144 | if (!isset($this->schema->indexes[$reindexProvider::getIndex()])) {
145 | continue;
146 | }
147 |
148 | if ($reindexProvider::getIndex() === $reindexConfig->getIndex() || null === $reindexConfig->getIndex()) {
149 | $reindexProvidersPerIndex[$reindexProvider::getIndex()][] = $reindexProvider;
150 | }
151 | }
152 |
153 | foreach ($reindexProvidersPerIndex as $index => $reindexProviders) {
154 | if ($reindexConfig->shouldDropIndex() && $this->existIndex($index)) {
155 | $task = $this->dropIndex($index, ['return_slow_promise_result' => true]);
156 | $task->wait();
157 | $task = $this->createIndex($index, ['return_slow_promise_result' => true]);
158 | $task->wait();
159 | } elseif (!$this->existIndex($index)) {
160 | $task = $this->createIndex($index, ['return_slow_promise_result' => true]);
161 | $task->wait();
162 | }
163 |
164 | foreach ($reindexProviders as $reindexProvider) {
165 | $this->bulk(
166 | $index,
167 | (function () use ($index, $reindexProvider, $reindexConfig, $progressCallback) {
168 | $count = 0;
169 | $total = $reindexProvider->total();
170 |
171 | $lastCount = -1;
172 | foreach ($reindexProvider->provide($reindexConfig) as $document) {
173 | ++$count;
174 |
175 | yield $document;
176 |
177 | if (null !== $progressCallback
178 | && 0 === ($count % $reindexConfig->getBulkSize())
179 | ) {
180 | $lastCount = $count;
181 | $progressCallback($index, $count, $total);
182 | }
183 | }
184 |
185 | if ($lastCount !== $count
186 | && null !== $progressCallback
187 | ) {
188 | $progressCallback($index, $count, $total);
189 | }
190 | })(),
191 | [],
192 | $reindexConfig->getBulkSize(),
193 | );
194 | }
195 | }
196 | }
197 | }
198 |
--------------------------------------------------------------------------------
/src/EngineInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal;
15 |
16 | use CmsIg\Seal\Exception\DocumentNotFoundException;
17 | use CmsIg\Seal\Reindex\ReindexConfig;
18 | use CmsIg\Seal\Reindex\ReindexProviderInterface;
19 | use CmsIg\Seal\Search\SearchBuilder;
20 | use CmsIg\Seal\Task\TaskInterface;
21 |
22 | interface EngineInterface
23 | {
24 | /**
25 | * @param array $document
26 | * @param array{return_slow_promise_result?: true} $options
27 | *
28 | * @return ($options is non-empty-array ? TaskInterface> : null)
29 | */
30 | public function saveDocument(string $index, array $document, array $options = []): TaskInterface|null;
31 |
32 | /**
33 | * @param array{return_slow_promise_result?: true} $options
34 | *
35 | * @return ($options is non-empty-array ? TaskInterface : null)
36 | */
37 | public function deleteDocument(string $index, string $identifier, array $options = []): TaskInterface|null;
38 |
39 | /**
40 | * @param iterable> $saveDocuments
41 | * @param iterable $deleteDocumentIdentifiers
42 | * @param array{return_slow_promise_result?: true} $options
43 | *
44 | * @return ($options is non-empty-array ? TaskInterface : null)
45 | */
46 | public function bulk(string $index, iterable $saveDocuments, iterable $deleteDocumentIdentifiers, int $bulkSize = 100, array $options = []): TaskInterface|null;
47 |
48 | /**
49 | * @throws DocumentNotFoundException
50 | *
51 | * @return array
52 | */
53 | public function getDocument(string $index, string $identifier): array;
54 |
55 | public function createSearchBuilder(string $index): SearchBuilder;
56 |
57 | /**
58 | * @param array{return_slow_promise_result?: true} $options
59 | *
60 | * @return ($options is non-empty-array ? TaskInterface : null)
61 | */
62 | public function createIndex(string $index, array $options = []): TaskInterface|null;
63 |
64 | /**
65 | * @param array{return_slow_promise_result?: true} $options
66 | *
67 | * @return ($options is non-empty-array ? TaskInterface : null)
68 | */
69 | public function dropIndex(string $index, array $options = []): TaskInterface|null;
70 |
71 | public function existIndex(string $index): bool;
72 |
73 | /**
74 | * @param array{return_slow_promise_result?: bool} $options
75 | *
76 | * @return ($options is non-empty-array ? TaskInterface : null)
77 | */
78 | public function createSchema(array $options = []): TaskInterface|null;
79 |
80 | /**
81 | * @param array{return_slow_promise_result?: bool} $options
82 | *
83 | * @return ($options is non-empty-array ? TaskInterface : null)
84 | */
85 | public function dropSchema(array $options = []): TaskInterface|null;
86 |
87 | /**
88 | * @experimental This method is experimental and may change in future versions, we are not sure if it stays here or the syntax change completely.
89 | * For framework users it is uninteresting as there it is handled via CLI commands.
90 | *
91 | * @param iterable $reindexProviders
92 | * @param callable(string, int, int|null): void|null $progressCallback
93 | */
94 | public function reindex(
95 | iterable $reindexProviders,
96 | ReindexConfig $reindexConfig,
97 | callable|null $progressCallback = null,
98 | ): void;
99 | }
100 |
--------------------------------------------------------------------------------
/src/EngineRegistry.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal;
15 |
16 | final class EngineRegistry
17 | {
18 | /**
19 | * @var array
20 | */
21 | private array $engines;
22 |
23 | /**
24 | * @param iterable $engines
25 | */
26 | public function __construct(
27 | iterable $engines,
28 | ) {
29 | $this->engines = [...$engines];
30 | }
31 |
32 | /**
33 | * @return iterable
34 | */
35 | public function getEngines(): iterable
36 | {
37 | return $this->engines;
38 | }
39 |
40 | public function getEngine(string $name): EngineInterface
41 | {
42 | if (!isset($this->engines[$name])) {
43 | throw new \InvalidArgumentException(
44 | 'Unknown Search engine: "' . $name . '" available engines are "' . \implode('", "', \array_keys($this->engines)) . '".',
45 | );
46 | }
47 |
48 | return $this->engines[$name];
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/Exception/DocumentNotFoundException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Exception;
15 |
16 | final class DocumentNotFoundException extends \Exception
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/src/Marshaller/FlattenMarshaller.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Marshaller;
15 |
16 | use CmsIg\Seal\Schema\Field;
17 |
18 | /**
19 | * @internal This class currently in discussion to be open for all adapters.
20 | *
21 | * The FlattenMarshaller will flatten all fields and save original document under a `_source` field.
22 | * The FlattenMarshaller should only be used when the Search Engine does not support nested objects and so
23 | * the Marshaller should used in many cases instead.
24 | */
25 | final class FlattenMarshaller
26 | {
27 | private readonly Marshaller $marshaller;
28 |
29 | private readonly Flattener $flattener;
30 |
31 | /**
32 | * @param array{
33 | * name?: string,
34 | * latitude?: string|int,
35 | * longitude?: string|int,
36 | * separator?: string,
37 | * multiple?: bool,
38 | * }|null $geoPointFieldConfig
39 | * @param non-empty-string $fieldSeparator
40 | */
41 | public function __construct(
42 | private readonly bool $dateAsInteger = false,
43 | private readonly bool $addRawFilterTextField = false,
44 | private readonly array|null $geoPointFieldConfig = null,
45 | private readonly string $fieldSeparator = '.',
46 | ) {
47 | $this->marshaller = new Marshaller(
48 | $this->dateAsInteger,
49 | $this->addRawFilterTextField,
50 | $this->geoPointFieldConfig,
51 | );
52 |
53 | $this->flattener = new Flattener(
54 | metadataKey: 's_metadata',
55 | fieldSeparator: $this->fieldSeparator,
56 | );
57 | }
58 |
59 | /**
60 | * @param Field\AbstractField[] $fields
61 | * @param array $document
62 | *
63 | * @return array
64 | */
65 | public function marshall(array $fields, array $document): array
66 | {
67 | $marshalledDocument = $this->marshaller->marshall($fields, $document);
68 |
69 | $geoFieldName = $this->findGeoFieldName($fields);
70 | $geoFieldValue = null;
71 | if (null !== $geoFieldName && \array_key_exists($geoFieldName, $marshalledDocument)) {
72 | $geoFieldValue = $marshalledDocument[$geoFieldName];
73 | unset($marshalledDocument[$geoFieldName]);
74 | }
75 |
76 | $flattenDocument = $this->flattener->flatten($marshalledDocument);
77 |
78 | if (null !== $geoFieldName) {
79 | $flattenDocument[$geoFieldName] = $geoFieldValue;
80 | }
81 |
82 | return $flattenDocument;
83 | }
84 |
85 | /**
86 | * @param Field\AbstractField[] $fields
87 | * @param array $raw
88 | *
89 | * @return array
90 | */
91 | public function unmarshall(array $fields, array $raw): array
92 | {
93 | $raw = \array_filter($raw, static fn ($value) => null !== $value);
94 |
95 | $geoFieldName = $this->findGeoFieldName($fields);
96 | $geoFieldValue = null;
97 | if (null !== $geoFieldName && \array_key_exists($geoFieldName, $raw)) {
98 | $geoFieldValue = $raw[$geoFieldName];
99 | unset($raw[$geoFieldName]);
100 | }
101 |
102 | $unflattenDocument = $this->flattener->unflatten($raw);
103 |
104 | if (null !== $geoFieldName && null !== $geoFieldValue) {
105 | $unflattenDocument[$geoFieldName] = $geoFieldValue;
106 | }
107 |
108 | $unmarshalledDocument = $this->marshaller->unmarshall($fields, $unflattenDocument);
109 |
110 | return $unmarshalledDocument;
111 | }
112 |
113 | /**
114 | * @param Field\AbstractField[] $fields
115 | */
116 | private function findGeoFieldName(array $fields): string|null
117 | {
118 | $geoFieldName = $this->geoPointFieldConfig['name'] ?? null;
119 | if (null !== $geoFieldName) {
120 | return $geoFieldName;
121 | }
122 |
123 | foreach ($fields as $field) {
124 | if ($field instanceof Field\GeoPointField) {
125 | return $field->name;
126 | }
127 | }
128 |
129 | return null;
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/src/Marshaller/Flattener.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Marshaller;
15 |
16 | /**
17 | * @internal this class currently can be modified or removed at any time
18 | */
19 | final class Flattener
20 | {
21 | /**
22 | * @param non-empty-string $metadataKey
23 | * @param non-empty-string $fieldSeparator
24 | * @param non-empty-string $metadataSeparator
25 | * @param non-empty-string $metadataPlaceholder
26 | */
27 | public function __construct(
28 | private readonly string $metadataKey = 's_metadata',
29 | private readonly string $fieldSeparator = '.',
30 | private readonly string $metadataSeparator = '/',
31 | private readonly string $metadataPlaceholder = '*',
32 | ) {
33 | }
34 |
35 | /**
36 | * @param array $data
37 | *
38 | * @return array
39 | */
40 | public function flatten(array $data): array
41 | {
42 | $flattenData = $this->doFlatten($data);
43 |
44 | $newData = [];
45 | $metadata = [];
46 | foreach ($flattenData as $key => $value) {
47 | unset($flattenData[$key]);
48 |
49 | /** @var string $metadataKey */
50 | $metadataKey = \preg_replace('/' . \preg_quote($this->metadataSeparator, '/') . '(\d+)' . \preg_quote($this->metadataSeparator, '/') . '/', $this->metadataSeparator, $key, -1);
51 | /** @var string $metadataKey */
52 | $metadataKey = \preg_replace('/' . \preg_quote($this->metadataSeparator, '/') . '(\d+)$/', '', $metadataKey, -1);
53 | $newKey = \str_replace($this->metadataSeparator, $this->fieldSeparator, $metadataKey);
54 |
55 | if ($newKey === $key) {
56 | $newData[$newKey] = $value;
57 |
58 | continue;
59 | }
60 |
61 | if ($metadataKey === $key) {
62 | $newData[$newKey] = $value;
63 | $newValue = [$value];
64 | } else {
65 | $newValue = \is_array($value) ? $value : [$value];
66 | $oldValue = ($newData[$newKey] ?? []);
67 |
68 | \assert(\is_array($oldValue), 'Expected old value of key "' . $newKey . '" to be an array got "' . \get_debug_type($oldValue) . '".');
69 |
70 | $newData[$newKey] = [
71 | ...$oldValue,
72 | ...$newValue,
73 | ];
74 | }
75 |
76 | if (\str_contains($metadataKey, $this->metadataSeparator)) {
77 | foreach ($newValue as $v) {
78 | $metadata[$metadataKey][] = \preg_replace_callback('/[^' . \preg_quote($this->metadataSeparator, '/') . ']+/', fn ($matches) => \is_numeric($matches[0]) ? $matches[0] : $this->metadataPlaceholder, $key);
79 | }
80 | }
81 | }
82 |
83 | if ([] !== $metadata) {
84 | $newData[$this->metadataKey] = \json_encode($metadata, \JSON_THROW_ON_ERROR);
85 | }
86 |
87 | return $newData;
88 | }
89 |
90 | /**
91 | * @param array $data
92 | *
93 | * @return array
94 | */
95 | private function doFlatten(array $data, string $prefix = ''): array
96 | {
97 | $newData = [];
98 | foreach ($data as $key => $value) {
99 | if (!\is_array($value)
100 | || [] === $value
101 | ) {
102 | $newData[$prefix . $key] = $value;
103 |
104 | continue;
105 | }
106 |
107 | $flattened = $this->doFlatten($value, $key . $this->metadataSeparator);
108 | foreach ($flattened as $subKey => $subValue) {
109 | $newData[$prefix . $subKey] = $subValue;
110 | }
111 | }
112 |
113 | return $newData;
114 | }
115 |
116 | /**
117 | * @param array $data
118 | *
119 | * @return array
120 | */
121 | public function unflatten(array $data): array
122 | {
123 | $newData = [];
124 | /** @var array> $metadata */
125 | $metadata = [];
126 | $metadataKeyMapping = [];
127 | if (\array_key_exists($this->metadataKey, $data)) {
128 | \assert(\is_string($data[$this->metadataKey]), 'Expected metadata to be a string.');
129 |
130 | /** @var array> $metadata */
131 | $metadata = \json_decode($data[$this->metadataKey], true, flags: \JSON_THROW_ON_ERROR);
132 |
133 | foreach (\array_keys($metadata) as $subMetadataKey) {
134 | $metadataKeyMapping[\str_replace($this->metadataSeparator, $this->fieldSeparator, $subMetadataKey)] = $subMetadataKey;
135 | }
136 |
137 | unset($data[$this->metadataKey]);
138 | }
139 |
140 | foreach ($data as $key => $value) {
141 | $metadataKey = $metadataKeyMapping[$key] ?? null;
142 | if (null === $metadataKey) {
143 | $newData[$key] = $value;
144 |
145 | continue;
146 | }
147 |
148 | $keyParts = \explode($this->metadataSeparator, $metadataKey);
149 | if (!\is_array($value)) {
150 | $value = [$value];
151 | }
152 |
153 | foreach ($value as $subKey => $subValue) {
154 | \assert(\array_key_exists($subKey, $metadata[$metadataKey]), 'Expected key "' . $subKey . '" to exist in "' . $key . '".');
155 |
156 | $keyPartsReplacements = $keyParts;
157 |
158 | /** @var string $newKeyPath */
159 | $newKeyPath = \preg_replace_callback('/' . \preg_quote($this->metadataPlaceholder, '/') . '/', function () use (&$keyPartsReplacements) {
160 | return \array_shift($keyPartsReplacements);
161 | }, $metadata[$metadataKey][$subKey]);
162 |
163 | $newSubData = &$newData;
164 | foreach (\explode($this->metadataSeparator, $newKeyPath) as $newKeyPart) {
165 | $newSubData = &$newSubData[$newKeyPart]; // @phpstan-ignore-line
166 | }
167 |
168 | $newSubData = $subValue;
169 | }
170 | }
171 |
172 | return $newData;
173 | }
174 | }
175 |
--------------------------------------------------------------------------------
/src/Marshaller/Marshaller.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Marshaller;
15 |
16 | use CmsIg\Seal\Schema\Field;
17 |
18 | /**
19 | * @internal This class currently in discussion to be open for all adapters.
20 | *
21 | * The Marshaller will split the typed fields into different subfields and use a `_originalIndex` field to unmarshall it again.
22 | */
23 | final class Marshaller
24 | {
25 | /**
26 | * @param array{
27 | * name?: string,
28 | * latitude?: string|int,
29 | * longitude?: string|int,
30 | * separator?: string,
31 | * multiple?: bool,
32 | * }|null $geoPointFieldConfig
33 | */
34 | public function __construct(
35 | private readonly bool $dateAsInteger = false,
36 | private readonly bool $addRawFilterTextField = false,
37 | private readonly array|null $geoPointFieldConfig = null,
38 | ) {
39 | }
40 |
41 | /**
42 | * @param Field\AbstractField[] $fields
43 | * @param array $document
44 | *
45 | * @return array
46 | */
47 | public function marshall(array $fields, array $document): array
48 | {
49 | $rawDocument = [];
50 |
51 | foreach ($fields as $name => $field) {
52 | if (!\array_key_exists($field->name, $document)) {
53 | continue;
54 | }
55 |
56 | if ($field->multiple && !\is_array($document[$field->name])) {
57 | throw new \RuntimeException('Field "' . $field->name . '" is multiple but value is not an array.');
58 | }
59 |
60 | match (true) {
61 | $field instanceof Field\ObjectField => $rawDocument[$name] = $this->marshallObjectFields($document[$field->name], $field), // @phpstan-ignore-line
62 | $field instanceof Field\TypedField => $rawDocument = \array_replace($rawDocument, $this->marhsallTypedFields($name, $document[$field->name], $field)), // @phpstan-ignore-line
63 | $field instanceof Field\DateTimeField => $rawDocument[$name] = $this->marshallDateTimeField($document[$field->name], $field), // @phpstan-ignore-line
64 | $field instanceof Field\GeoPointField => $rawDocument[$this->geoPointFieldConfig['name'] ?? $name] = $this->marshallGeoPointField($document[$field->name], $field), // @phpstan-ignore-line
65 | default => $rawDocument[$name] = $document[$field->name],
66 | };
67 |
68 | if ($this->addRawFilterTextField
69 | && $field instanceof Field\TextField && $field->searchable && ($field->sortable || $field->filterable)
70 | ) {
71 | $rawDocument[$name . '.raw'] = $rawDocument[$name];
72 | }
73 | }
74 |
75 | return $rawDocument;
76 | }
77 |
78 | /**
79 | * @param array{latitude: float, longitude: float}|null $value
80 | *
81 | * @return array|float|string>|string|null
82 | */
83 | private function marshallGeoPointField(array|null $value, Field\GeoPointField $field): array|string|null
84 | {
85 | if ($field->multiple) {
86 | throw new \LogicException('GeoPointField currently does not support multiple values.');
87 | }
88 |
89 | if ($value) {
90 | $value = [
91 | $this->geoPointFieldConfig['latitude'] ?? 'latitude' => $value['latitude'],
92 | $this->geoPointFieldConfig['longitude'] ?? 'longitude' => $value['longitude'],
93 | ];
94 |
95 | \ksort($value); // for redisearch we need invert the order
96 |
97 | if ($this->geoPointFieldConfig['separator'] ?? false) {
98 | $value = \implode($this->geoPointFieldConfig['separator'], $value);
99 | }
100 |
101 | if ($this->geoPointFieldConfig['multiple'] ?? false) {
102 | $value = [$value];
103 | }
104 |
105 | return $value;
106 | }
107 |
108 | return null;
109 | }
110 |
111 | /**
112 | * @param string|string[]|null $value
113 | *
114 | * @return int|string|string[]|int[]|null
115 | */
116 | private function marshallDateTimeField(string|array|null $value, Field\DateTimeField $field): int|string|array|null
117 | {
118 | if ($field->multiple) {
119 | /** @var string[]|null $value */
120 |
121 | return \array_map(function ($value) {
122 | if (null !== $value && $this->dateAsInteger) {
123 | /** @var int */
124 | return \strtotime($value);
125 | }
126 |
127 | return $value;
128 | }, (array) $value);
129 | }
130 |
131 | /** @var string|null $value */
132 | if (null !== $value && $this->dateAsInteger) {
133 | /** @var int */
134 | return \strtotime($value);
135 | }
136 |
137 | return $value;
138 | }
139 |
140 | /**
141 | * @param array $document
142 | *
143 | * @return array|array>
144 | */
145 | private function marshallObjectFields(array $document, Field\ObjectField $field): array
146 | {
147 | if (!$field->multiple) {
148 | return $this->marshall($field->fields, $document);
149 | }
150 |
151 | /** @var array> $rawDocuments */
152 | $rawDocuments = [];
153 | /** @var array $data */
154 | foreach ($document as $data) {
155 | $rawDocuments[] = $this->marshall($field->fields, $data);
156 | }
157 |
158 | return $rawDocuments;
159 | }
160 |
161 | /**
162 | * @param array|array> $document
163 | *
164 | * @return array
165 | */
166 | private function marhsallTypedFields(string $name, array $document, Field\TypedField $field): array
167 | {
168 | $rawFields = [];
169 |
170 | if (!$field->multiple) {
171 | $document = [$document];
172 | }
173 |
174 | /** @var array $data */
175 | foreach ($document as $originalIndex => $data) {
176 | /** @var string|null $type */
177 | $type = $data[$field->typeField] ?? null;
178 | if (null === $type || !\array_key_exists($type, $field->types)) {
179 | throw new \RuntimeException('Expected type field "' . $field->typeField . '" not found in document.');
180 | }
181 |
182 | $typedFields = $field->types[$type];
183 |
184 | $rawData = \array_replace($field->multiple ? [
185 | '_originalIndex' => $originalIndex,
186 | ] : [], $this->marshall($typedFields, $data));
187 |
188 | if ($field->multiple) {
189 | $rawFields[$name][$type][] = $rawData;
190 |
191 | continue;
192 | }
193 |
194 | $rawFields[$name][$type] = $rawData;
195 | }
196 |
197 | return $rawFields;
198 | }
199 |
200 | /**
201 | * @param Field\AbstractField[] $fields
202 | * @param array $raw
203 | *
204 | * @return array
205 | */
206 | public function unmarshall(array $fields, array $raw): array
207 | {
208 | $document = [];
209 |
210 | foreach ($fields as $name => $field) {
211 | if (
212 | (
213 | !\array_key_exists($name, $raw)
214 | && !$field instanceof Field\TypedField
215 | )
216 | && (!$field instanceof Field\GeoPointField || !\array_key_exists($this->geoPointFieldConfig['name'] ?? $name, $raw))
217 | ) {
218 | continue;
219 | }
220 |
221 | match (true) {
222 | $field instanceof Field\ObjectField => $document[$field->name] = $this->unmarshallObjectFields($raw[$name], $field), // @phpstan-ignore-line
223 | $field instanceof Field\TypedField => $document = \array_replace($document, $this->unmarshallTypedFields($name, $raw, $field)),
224 | $field instanceof Field\DateTimeField => $document[$name] = $this->unmarshallDateTimeField($raw[$field->name], $field), // @phpstan-ignore-line
225 | $field instanceof Field\GeoPointField => $document[$name] = $this->unmarshallGeoPointField($raw, $field),
226 | default => $document[$field->name] = $raw[$name] ?? ($field->multiple ? [] : null),
227 | };
228 | }
229 |
230 | return $document;
231 | }
232 |
233 | /**
234 | * @param array|array> $raw
235 | *
236 | * @return array
237 | */
238 | private function unmarshallTypedFields(string $name, array $raw, Field\TypedField $field): array
239 | {
240 | $documentFields = [];
241 |
242 | foreach ($field->types as $type => $typedFields) {
243 | if (!isset($raw[$name][$type])) { // @phpstan-ignore-line
244 | continue;
245 | }
246 |
247 | /** @var array> $dataList */
248 | $dataList = $field->multiple ? $raw[$name][$type] : [$raw[$name][$type]];
249 |
250 | foreach ($dataList as $data) {
251 | $documentData = \array_replace([$field->typeField => $type], $this->unmarshall($typedFields, $data));
252 |
253 | if ($field->multiple) {
254 | /** @var string|int|null $originalIndex */
255 | $originalIndex = $data['_originalIndex'] ?? null;
256 | if (null === $originalIndex) {
257 | throw new \RuntimeException('Expected "_originalIndex" field not found in document.');
258 | }
259 |
260 | $documentFields[$name][$originalIndex] = $documentData;
261 |
262 | \ksort($documentFields[$name]);
263 |
264 | continue;
265 | }
266 |
267 | $documentFields[$name] = $documentData;
268 | }
269 | }
270 |
271 | return $documentFields;
272 | }
273 |
274 | /**
275 | * @param array|array> $raw
276 | *
277 | * @return array|array>
278 | */
279 | private function unmarshallObjectFields(array $raw, Field\ObjectField $field): array
280 | {
281 | if (!$field->multiple) {
282 | return $this->unmarshall($field->fields, $raw);
283 | }
284 |
285 | /** @var array> $documentFields */
286 | $documentFields = [];
287 |
288 | /** @var array $data */
289 | foreach ($raw as $data) {
290 | $documentFields[] = $this->unmarshall($field->fields, $data);
291 | }
292 |
293 | return $documentFields;
294 | }
295 |
296 | /**
297 | * @param string|int|string[]|int[]|null $value
298 | *
299 | * @return string|string[]
300 | */
301 | private function unmarshallDateTimeField(string|int|array|null $value, Field\DateTimeField $field): string|array|null
302 | {
303 | if ($field->multiple) {
304 | return \array_map(function ($value) {
305 | if (null !== $value && $this->dateAsInteger) {
306 | /** @var int $value */
307 |
308 | return \date('c', $value);
309 | }
310 |
311 | if (\is_string($value) && \str_ends_with($value, 'Z')) {
312 | /** @var int $time */
313 | $time = \strtotime($value);
314 |
315 | return \date('c', $time);
316 | }
317 |
318 | /** @var string */
319 | return $value;
320 | }, (array) $value);
321 | }
322 |
323 | if (null !== $value && $this->dateAsInteger) {
324 | /** @var int $value */
325 |
326 | return \date('c', $value);
327 | }
328 |
329 | if (\is_string($value) && \str_ends_with($value, 'Z')) {
330 | /** @var int $time */
331 | $time = \strtotime($value);
332 |
333 | return \date('c', $time);
334 | }
335 |
336 | /** @var string|null */
337 | return $value;
338 | }
339 |
340 | /**
341 | * @param array $document
342 | *
343 | * @return array{latitude: float, longitude: float}|null
344 | */
345 | private function unmarshallGeoPointField(array $document, Field\GeoPointField $field): array|null
346 | {
347 | if ($field->multiple) {
348 | throw new \LogicException('GeoPointField currently does not support multiple values.');
349 | }
350 |
351 | /** @var array|float|string>|string|null $value */
352 | $value = $document[$this->geoPointFieldConfig['name'] ?? $field->name] ?? null;
353 |
354 | if ($value) {
355 | if ($this->geoPointFieldConfig['multiple'] ?? false) {
356 | $value = $value[0] ?? $value;
357 | }
358 |
359 | $separator = $this->geoPointFieldConfig['separator'] ?? false;
360 | if ($separator) {
361 | \assert(\is_string($value), 'Expected to have a string value for a separated geo point field.');
362 | $value = \explode($separator, $value);
363 | }
364 |
365 | $latitude = $value[$this->geoPointFieldConfig['latitude'] ?? 'latitude'] ?? null;
366 | $longitude = $value[$this->geoPointFieldConfig['longitude'] ?? 'longitude'] ?? null;
367 |
368 | \assert(null !== $latitude && null !== $longitude, 'Expected to have latitude and longitude in geo point field.');
369 |
370 | return [
371 | 'latitude' => (float) $latitude,
372 | 'longitude' => (float) $longitude,
373 | ];
374 | }
375 |
376 | return null;
377 | }
378 | }
379 |
--------------------------------------------------------------------------------
/src/Reindex/ReindexConfig.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Reindex;
15 |
16 | final class ReindexConfig
17 | {
18 | private string|null $index = null;
19 | private bool $dropIndex = false;
20 | private int $bulkSize = 100;
21 | private \DateTimeInterface|null $dateTimeBoundary = null;
22 |
23 | /**
24 | * @var array
25 | */
26 | private array $identifiers = [];
27 |
28 | public function getIndex(): string|null
29 | {
30 | return $this->index;
31 | }
32 |
33 | public function shouldDropIndex(): bool
34 | {
35 | return $this->dropIndex;
36 | }
37 |
38 | public function getBulkSize(): int
39 | {
40 | return $this->bulkSize;
41 | }
42 |
43 | public function getDateTimeBoundary(): \DateTimeInterface|null
44 | {
45 | return $this->dateTimeBoundary;
46 | }
47 |
48 | /**
49 | * @return array
50 | */
51 | public function getIdentifiers(): array
52 | {
53 | return $this->identifiers;
54 | }
55 |
56 | public function withDateTimeBoundary(\DateTimeInterface|null $dateTimeBoundary): self
57 | {
58 | $clone = clone $this;
59 | $clone->dateTimeBoundary = $dateTimeBoundary;
60 |
61 | return $clone;
62 | }
63 |
64 | /**
65 | * @param array $identifiers
66 | */
67 | public function withIdentifiers(array $identifiers): self
68 | {
69 | $clone = clone $this;
70 | $clone->identifiers = $identifiers;
71 |
72 | return $clone;
73 | }
74 |
75 | public function withBulkSize(int $bulkSize): self
76 | {
77 | $clone = clone $this;
78 | $clone->bulkSize = $bulkSize;
79 |
80 | return $clone;
81 | }
82 |
83 | public function withIndex(string|null $index): self
84 | {
85 | $clone = clone $this;
86 | $clone->index = $index;
87 |
88 | return $clone;
89 | }
90 |
91 | public function withDropIndex(bool $dropIndex): self
92 | {
93 | $clone = clone $this;
94 | $clone->dropIndex = $dropIndex;
95 |
96 | return $clone;
97 | }
98 |
99 | public static function create(): self
100 | {
101 | return new self();
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/Reindex/ReindexProviderInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Reindex;
15 |
16 | interface ReindexProviderInterface
17 | {
18 | /**
19 | * Returns how many documents this provider will provide. Returns `null` if the total is unknown.
20 | */
21 | public function total(): int|null;
22 |
23 | /**
24 | * The reindex provider returns a Generator which provides the documents to reindex.
25 | *
26 | * @return \Generator>
27 | */
28 | public function provide(ReindexConfig $reindexConfig): \Generator;
29 |
30 | /**
31 | * The name of the index for which the documents are for.
32 | */
33 | public static function getIndex(): string;
34 | }
35 |
--------------------------------------------------------------------------------
/src/Schema/Exception/FieldByPathNotFoundException.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Schema\Exception;
15 |
16 | final class FieldByPathNotFoundException extends \Exception
17 | {
18 | public function __construct(string $indexName, string $path, \Throwable|null $previous = null)
19 | {
20 | parent::__construct(
21 | 'Field path "' . $path . '" not found in index "' . $indexName . '"',
22 | 0,
23 | $previous,
24 | );
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Schema/Field/AbstractField.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Schema\Field;
15 |
16 | /**
17 | * @readonly
18 | */
19 | abstract class AbstractField
20 | {
21 | /**
22 | * @param array $options
23 | */
24 | public function __construct(
25 | public readonly string $name,
26 | public readonly bool $multiple,
27 | public readonly bool $searchable,
28 | public readonly bool $filterable,
29 | public readonly bool $sortable,
30 | public readonly array $options,
31 | ) {
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Schema/Field/BooleanField.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Schema\Field;
15 |
16 | /**
17 | * Type to store true or false flags.
18 | *
19 | * @property false $searchable
20 | *
21 | * @readonly
22 | */
23 | final class BooleanField extends AbstractField
24 | {
25 | /**
26 | * @param false $searchable
27 | * @param array $options
28 | */
29 | public function __construct(
30 | string $name,
31 | bool $multiple = false,
32 | bool $searchable = false,
33 | bool $filterable = false,
34 | bool $sortable = false,
35 | array $options = [],
36 | ) {
37 | if ($searchable) { // @phpstan-ignore-line
38 | throw new \InvalidArgumentException('Searchability for BooleanField is not yet implemented: https://github.com/php-cmsig/search/issues/97');
39 | }
40 |
41 | parent::__construct(
42 | $name,
43 | $multiple,
44 | $searchable,
45 | $filterable,
46 | $sortable,
47 | $options,
48 | );
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/Schema/Field/DateTimeField.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Schema\Field;
15 |
16 | /**
17 | * Type to store date and date times.
18 | *
19 | * @property false $searchable
20 | *
21 | * @readonly
22 | */
23 | final class DateTimeField extends AbstractField
24 | {
25 | /**
26 | * @param false $searchable
27 | * @param array $options
28 | */
29 | public function __construct(
30 | string $name,
31 | bool $multiple = false,
32 | bool $searchable = false,
33 | bool $filterable = false,
34 | bool $sortable = false,
35 | array $options = [],
36 | ) {
37 | if ($searchable) { // @phpstan-ignore-line
38 | throw new \InvalidArgumentException('Searchability for DateTimeField is not yet implemented: https://github.com/php-cmsig/search/issues/97');
39 | }
40 |
41 | parent::__construct(
42 | $name,
43 | $multiple,
44 | $searchable,
45 | $filterable,
46 | $sortable,
47 | $options,
48 | );
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/Schema/Field/FloatField.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Schema\Field;
15 |
16 | /**
17 | * Type to store any PHP float value.
18 | *
19 | * @property false $searchable
20 | *
21 | * @readonly
22 | */
23 | final class FloatField extends AbstractField
24 | {
25 | /**
26 | * @param false $searchable
27 | * @param array $options
28 | */
29 | public function __construct(
30 | string $name,
31 | bool $multiple = false,
32 | bool $searchable = false,
33 | bool $filterable = false,
34 | bool $sortable = false,
35 | array $options = [],
36 | ) {
37 | if ($searchable) { // @phpstan-ignore-line
38 | throw new \InvalidArgumentException('Searchability for FloatField is not yet implemented: https://github.com/php-cmsig/search/issues/97');
39 | }
40 |
41 | parent::__construct(
42 | $name,
43 | $multiple,
44 | $searchable,
45 | $filterable,
46 | $sortable,
47 | $options,
48 | );
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/Schema/Field/GeoPointField.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Schema\Field;
15 |
16 | /**
17 | * Type to store geopoint with latitude and longitude.
18 | *
19 | * latitude: -90 to 90
20 | * longitude: -180 to 180
21 | *
22 | * ATTENTION: Different search engines support only one field for geopoint per index.
23 | *
24 | * @property false $searchable
25 | *
26 | * @readonly
27 | */
28 | final class GeoPointField extends AbstractField
29 | {
30 | /**
31 | * @param false $searchable
32 | * @param false $multiple
33 | * @param array $options
34 | */
35 | public function __construct(
36 | string $name,
37 | bool $multiple = false,
38 | bool $searchable = false,
39 | bool $filterable = false,
40 | bool $sortable = false,
41 | array $options = [],
42 | ) {
43 | if ($searchable) { // @phpstan-ignore-line
44 | throw new \InvalidArgumentException('Searchability for GeoPointField is not yet implemented: https://github.com/php-cmsig/search/issues/97');
45 | }
46 |
47 | parent::__construct(
48 | $name,
49 | $multiple,
50 | $searchable,
51 | $filterable,
52 | $sortable,
53 | $options,
54 | );
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/Schema/Field/IdentifierField.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Schema\Field;
15 |
16 | /**
17 | * Type to store the identifier this field type only exist once per index.
18 | *
19 | * @property false $multiple
20 | * @property false $searchable
21 | * @property true $filterable
22 | * @property true $sortable
23 | *
24 | * @readonly
25 | */
26 | final class IdentifierField extends AbstractField
27 | {
28 | public function __construct(string $name)
29 | {
30 | parent::__construct(
31 | $name,
32 | multiple: false,
33 | searchable: false,
34 | filterable: true,
35 | sortable: true,
36 | options: [],
37 | );
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/Schema/Field/IntegerField.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Schema\Field;
15 |
16 | /**
17 | * Type to store any PHP int value.
18 | *
19 | * @property false $searchable
20 | *
21 | * @readonly
22 | */
23 | final class IntegerField extends AbstractField
24 | {
25 | /**
26 | * @param false $searchable
27 | * @param array $options
28 | */
29 | public function __construct(
30 | string $name,
31 | bool $multiple = false,
32 | bool $searchable = false,
33 | bool $filterable = false,
34 | bool $sortable = false,
35 | array $options = [],
36 | ) {
37 | if ($searchable) { // @phpstan-ignore-line
38 | throw new \InvalidArgumentException('Searchability for IntegerField is not yet implemented: https://github.com/php-cmsig/search/issues/97');
39 | }
40 |
41 | parent::__construct(
42 | $name,
43 | $multiple,
44 | $searchable,
45 | $filterable,
46 | $sortable,
47 | $options,
48 | );
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/Schema/Field/ObjectField.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Schema\Field;
15 |
16 | /**
17 | * Type to store fields inside a nested object.
18 | *
19 | * @readonly
20 | */
21 | final class ObjectField extends AbstractField
22 | {
23 | /**
24 | * @param array $fields
25 | * @param array $options
26 | */
27 | public function __construct(
28 | string $name,
29 | readonly public array $fields,
30 | bool $multiple = false,
31 | array $options = [],
32 | ) {
33 | $searchable = false;
34 | $filterable = false;
35 | $sortable = false;
36 |
37 | foreach ($fields as $field) {
38 | if ($field->searchable) {
39 | $searchable = true;
40 | }
41 |
42 | if ($field->filterable) {
43 | $filterable = true;
44 | }
45 |
46 | if ($field->sortable) {
47 | $sortable = true;
48 | }
49 | }
50 |
51 | parent::__construct(
52 | $name,
53 | $multiple,
54 | $searchable,
55 | $filterable,
56 | $sortable,
57 | $options,
58 | );
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/Schema/Field/TextField.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Schema\Field;
15 |
16 | /**
17 | * Type to store any text, options can maybe use to specify it more specific.
18 | */
19 | final class TextField extends AbstractField
20 | {
21 | /**
22 | * @param array $options
23 | *
24 | * @readonly
25 | */
26 | public function __construct(
27 | string $name,
28 | bool $multiple = false,
29 | bool $searchable = true,
30 | bool $filterable = false,
31 | bool $sortable = false,
32 | array $options = [],
33 | ) {
34 | parent::__construct(
35 | $name,
36 | $multiple,
37 | $searchable,
38 | $filterable,
39 | $sortable,
40 | $options,
41 | );
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Schema/Field/TypedField.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Schema\Field;
15 |
16 | /**
17 | * Type to store any text, options can maybe use to specify it more specific.
18 | */
19 | final class TypedField extends AbstractField
20 | {
21 | /**
22 | * @param array> $types
23 | * @param array $options
24 | *
25 | * @readonly
26 | */
27 | public function __construct(
28 | string $name,
29 | public readonly string $typeField,
30 | public readonly array $types,
31 | bool $multiple = false,
32 | array $options = [],
33 | ) {
34 | $searchable = false;
35 | $filterable = false;
36 | $sortable = false;
37 |
38 | foreach ($types as $fields) {
39 | foreach ($fields as $field) {
40 | if ($field->searchable) {
41 | $searchable = true;
42 | }
43 |
44 | if ($field->filterable) {
45 | $filterable = true;
46 | }
47 |
48 | if ($field->sortable) {
49 | $sortable = true;
50 | }
51 | }
52 | }
53 |
54 | parent::__construct(
55 | $name,
56 | $multiple,
57 | $searchable,
58 | $filterable,
59 | $sortable,
60 | $options,
61 | );
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/Schema/Index.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Schema;
15 |
16 | use CmsIg\Seal\Schema\Exception\FieldByPathNotFoundException;
17 | use CmsIg\Seal\Schema\Field\AbstractField;
18 | use CmsIg\Seal\Schema\Field\GeoPointField;
19 | use CmsIg\Seal\Schema\Field\IdentifierField;
20 | use CmsIg\Seal\Schema\Field\ObjectField;
21 | use CmsIg\Seal\Schema\Field\TypedField;
22 |
23 | /**
24 | * @readonly
25 | */
26 | final class Index
27 | {
28 | private readonly IdentifierField|null $identifierField;
29 |
30 | /**
31 | * @var string[]
32 | */
33 | public readonly array $searchableFields;
34 |
35 | /**
36 | * @var string[]
37 | */
38 | public readonly array $sortableFields;
39 |
40 | /**
41 | * @var string[]
42 | */
43 | public readonly array $filterableFields;
44 |
45 | /**
46 | * @param array $fields
47 | * @param array $options
48 | */
49 | public function __construct(
50 | public readonly string $name,
51 | public readonly array $fields,
52 | public readonly array $options = [],
53 | ) {
54 | $attributes = $this->getAttributes($fields);
55 | $this->searchableFields = $attributes['searchableFields'];
56 | $this->filterableFields = $attributes['filterableFields'];
57 | $this->sortableFields = $attributes['sortableFields'];
58 | $this->identifierField = $attributes['identifierField'];
59 | }
60 |
61 | public function getIdentifierField(): IdentifierField
62 | {
63 | if (!$this->identifierField instanceof Field\IdentifierField) { // validating the identifierField here as merged Index configuration could be have no identifier
64 | throw new \LogicException(
65 | 'No "IdentifierField" found for index "' . $this->name . '" but is required.',
66 | );
67 | }
68 |
69 | return $this->identifierField;
70 | }
71 |
72 | public function getGeoPointField(): GeoPointField|null
73 | {
74 | foreach ($this->fields as $field) {
75 | if ($field instanceof GeoPointField) {
76 | return $field;
77 | }
78 | }
79 |
80 | return null;
81 | }
82 |
83 | public function getFieldByPath(string $path): AbstractField
84 | {
85 | $pathParts = \explode('.', $path);
86 | $fields = $this->fields;
87 |
88 | while (true) {
89 | $field = $fields[\current($pathParts)] ?? null;
90 | \next($pathParts);
91 |
92 | if ($field instanceof TypedField) {
93 | $fields = $field->types[\current($pathParts)];
94 | \next($pathParts);
95 | } elseif ($field instanceof ObjectField) {
96 | $fields = $field->fields;
97 | } elseif ($field instanceof AbstractField) {
98 | return $field;
99 | } else {
100 | throw new FieldByPathNotFoundException($this->name, $path);
101 | }
102 | }
103 | }
104 |
105 | /**
106 | * @param Field\AbstractField[] $fields
107 | *
108 | * @return ($withoutIdentifierField is false ? array{
109 | * searchableFields: string[],
110 | * filterableFields: string[],
111 | * sortableFields: string[],
112 | * identifierField: IdentifierField|null,
113 | * } : array{
114 | * searchableFields: string[],
115 | * filterableFields: string[],
116 | * sortableFields: string[],
117 | * })
118 | */
119 | private function getAttributes(array $fields, bool $withoutIdentifierField = false): array
120 | {
121 | $identifierField = null;
122 |
123 | $attributes = [
124 | 'searchableFields' => [],
125 | 'filterableFields' => [],
126 | 'sortableFields' => [],
127 | ];
128 |
129 | foreach ($fields as $name => $field) {
130 | \assert(
131 | (string) $name === $field->name, // this may change in future, see https://github.com/php-cmsig/search/issues/200
132 | \sprintf(
133 | 'A field named "%s" does not match key "%s" in index "%s", this is at current state required and may change in future.',
134 | $field->name,
135 | $name,
136 | $this->name,
137 | ),
138 | );
139 |
140 | \assert(
141 | 1 === \preg_match('/^([a-z]|[A-Z])\w+$/', $field->name), // see https://regex101.com/r/xR9G6D/1
142 | \sprintf(
143 | 'A field named "%s" in index "%s" uses unsupported format, supported characters are "a-z", "A-Z", "0-9" and "_" and the name must start with a letter.',
144 | $field->name,
145 | $this->name,
146 | ),
147 | );
148 |
149 | if ($field instanceof Field\ObjectField) {
150 | foreach ($this->getAttributes($field->fields, true) as $attributeType => $fieldNames) {
151 | foreach ($fieldNames as $fieldName) {
152 | $attributes[$attributeType][] = $name . '.' . $fieldName;
153 | }
154 | }
155 |
156 | continue;
157 | } elseif ($field instanceof Field\TypedField) {
158 | foreach ($field->types as $type => $fields) {
159 | foreach ($this->getAttributes($fields, true) as $attributeType => $fieldNames) {
160 | foreach ($fieldNames as $fieldName) {
161 | $attributes[$attributeType][] = $name . '.' . $type . '.' . $fieldName;
162 | }
163 | }
164 | }
165 |
166 | continue;
167 | }
168 |
169 | if ($field->searchable) {
170 | $attributes['searchableFields'][] = $name;
171 | }
172 |
173 | if ($field->filterable) {
174 | $attributes['filterableFields'][] = $name;
175 | }
176 |
177 | if ($field->sortable) {
178 | $attributes['sortableFields'][] = $name;
179 | }
180 |
181 | if ($field instanceof IdentifierField) {
182 | $identifierField = $field;
183 | }
184 | }
185 |
186 | if ($withoutIdentifierField) {
187 | return $attributes;
188 | }
189 |
190 | $attributes['identifierField'] = $identifierField;
191 |
192 | return $attributes;
193 | }
194 | }
195 |
--------------------------------------------------------------------------------
/src/Schema/Loader/LoaderInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Schema\Loader;
15 |
16 | use CmsIg\Seal\Schema\Schema;
17 |
18 | interface LoaderInterface
19 | {
20 | public function load(): Schema;
21 | }
22 |
--------------------------------------------------------------------------------
/src/Schema/Loader/PhpFileLoader.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Schema\Loader;
15 |
16 | use CmsIg\Seal\Schema\Field;
17 | use CmsIg\Seal\Schema\Index;
18 | use CmsIg\Seal\Schema\Schema;
19 |
20 | final class PhpFileLoader implements LoaderInterface
21 | {
22 | /**
23 | * @param string[] $directories
24 | */
25 | public function __construct(
26 | private readonly array $directories,
27 | private readonly string $indexNamePrefix = '',
28 | ) {
29 | }
30 |
31 | public function load(): Schema
32 | {
33 | /** @var Index[] $indexes */
34 | $indexes = [];
35 |
36 | foreach ($this->directories as $directory) {
37 | $iterator = new \RecursiveIteratorIterator(
38 | new \RecursiveDirectoryIterator($directory),
39 | \RecursiveIteratorIterator::LEAVES_ONLY,
40 | );
41 |
42 | $pathIndexes = [];
43 | foreach ($iterator as $file) {
44 | if (!$file instanceof \SplFileInfo) {
45 | continue;
46 | }
47 |
48 | if ('php' !== $file->getFileInfo()->getExtension()) {
49 | continue;
50 | }
51 |
52 | $index = require $file->getRealPath();
53 |
54 | if (!$index instanceof Index) {
55 | throw new \RuntimeException(\sprintf('File "%s" must return an instance of "%s".', $file->getRealPath(), Index::class));
56 | }
57 |
58 | $pathIndexes[$file->getRealPath()] = $index;
59 | }
60 |
61 | \ksort($pathIndexes); // make sure to import the files on all system in the same order
62 |
63 | foreach ($pathIndexes as $index) {
64 | $name = $index->name;
65 | if (isset($indexes[$name])) {
66 | $index = new Index($this->indexNamePrefix . $name, $this->mergeFields($indexes[$index->name]->fields, $index->fields), $this->mergeOptions($indexes[$index->name]->options, $index->options));
67 | } else {
68 | $index = new Index($this->indexNamePrefix . $name, $index->fields, $index->options);
69 | }
70 |
71 | $indexes[$name] = $index;
72 | }
73 | }
74 |
75 | return new Schema($indexes);
76 | }
77 |
78 | /**
79 | * @param Field\AbstractField[] $fields
80 | * @param Field\AbstractField[] $newFields
81 | *
82 | * @return Field\AbstractField[]
83 | */
84 | private function mergeFields(array $fields, array $newFields): array
85 | {
86 | foreach ($newFields as $name => $newField) {
87 | if (isset($fields[$name])) {
88 | if ($fields[$name]::class !== $newField::class) {
89 | throw new \RuntimeException(\sprintf('Field "%s" must be of type "%s" but "%s" given.', $name, $fields[$name]::class, $newField::class));
90 | }
91 |
92 | $newField = $this->mergeField($fields[$name], $newField);
93 | }
94 |
95 | $fields[$newField->name] = $newField;
96 | }
97 |
98 | return $fields;
99 | }
100 |
101 | /**
102 | * @param array $options
103 | * @param array $newOptions
104 | *
105 | * @return array
106 | */
107 | private function mergeOptions(array $options, array $newOptions): array
108 | {
109 | return \array_replace_recursive($options, $newOptions);
110 | }
111 |
112 | /**
113 | * @template T of Field\AbstractField
114 | *
115 | * @param T $field
116 | * @param T $newField
117 | *
118 | * @return T
119 | */
120 | private function mergeField(Field\AbstractField $field, Field\AbstractField $newField): Field\AbstractField
121 | {
122 | if ($newField instanceof Field\IdentifierField) {
123 | return $newField;
124 | }
125 |
126 | if ($field instanceof Field\TextField && $newField instanceof Field\TextField) {
127 | // @phpstan-ignore-next-line
128 | return new Field\TextField(
129 | $newField->name,
130 | multiple: $newField->multiple,
131 | searchable: $newField->searchable,
132 | filterable: $newField->filterable,
133 | sortable: $newField->sortable,
134 | options: \array_replace_recursive($field->options, $newField->options),
135 | );
136 | }
137 |
138 | if ($field instanceof Field\IntegerField && $newField instanceof Field\IntegerField) {
139 | // @phpstan-ignore-next-line
140 | return new Field\IntegerField(
141 | $newField->name,
142 | multiple: $newField->multiple,
143 | searchable: $newField->searchable,
144 | filterable: $newField->filterable,
145 | sortable: $newField->sortable,
146 | options: \array_replace_recursive($field->options, $newField->options),
147 | );
148 | }
149 |
150 | if ($field instanceof Field\FloatField && $newField instanceof Field\FloatField) {
151 | // @phpstan-ignore-next-line
152 | return new Field\FloatField(
153 | $newField->name,
154 | multiple: $newField->multiple,
155 | searchable: $newField->searchable,
156 | filterable: $newField->filterable,
157 | sortable: $newField->sortable,
158 | options: \array_replace_recursive($field->options, $newField->options),
159 | );
160 | }
161 |
162 | if ($field instanceof Field\DateTimeField && $newField instanceof Field\DateTimeField) {
163 | // @phpstan-ignore-next-line
164 | return new Field\DateTimeField(
165 | $newField->name,
166 | multiple: $newField->multiple,
167 | searchable: $newField->searchable,
168 | filterable: $newField->filterable,
169 | sortable: $newField->sortable,
170 | options: \array_replace_recursive($field->options, $newField->options),
171 | );
172 | }
173 |
174 | if ($field instanceof Field\ObjectField && $newField instanceof Field\ObjectField) {
175 | // @phpstan-ignore-next-line
176 | return new Field\ObjectField(
177 | $newField->name,
178 | fields: $this->mergeFields($field->fields, $newField->fields),
179 | multiple: $newField->multiple,
180 | options: \array_replace_recursive($field->options, $newField->options),
181 | );
182 | }
183 |
184 | if ($field instanceof Field\TypedField && $newField instanceof Field\TypedField) {
185 | $types = $field->types;
186 | foreach ($newField->types as $name => $newTypedFields) {
187 | if (isset($types[$name])) {
188 | $types[$name] = $this->mergeFields($types[$name], $newTypedFields);
189 |
190 | continue;
191 | }
192 |
193 | $types[$name] = $newTypedFields;
194 | }
195 |
196 | // @phpstan-ignore-next-line
197 | return new Field\TypedField(
198 | $newField->name,
199 | typeField: $newField->typeField,
200 | types: $types,
201 | multiple: $newField->multiple,
202 | options: \array_replace_recursive($field->options, $newField->options),
203 | );
204 | }
205 |
206 | throw new \RuntimeException(\sprintf(
207 | 'Field "%s" must be of type "%s" but "%s" and "%s" given.',
208 | $field->name,
209 | Field\AbstractField::class,
210 | $field::class,
211 | $newField::class,
212 | ));
213 | }
214 | }
215 |
--------------------------------------------------------------------------------
/src/Schema/Schema.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Schema;
15 |
16 | /**
17 | * @readonly
18 | */
19 | final class Schema
20 | {
21 | /**
22 | * @param array $indexes
23 | */
24 | public function __construct(
25 | public readonly array $indexes,
26 | ) {
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/Search/Condition/AbstractGroupCondition.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search\Condition;
15 |
16 | /**
17 | * @internal this class is internal please use AndCondition or OrCondition directly
18 | */
19 | abstract class AbstractGroupCondition
20 | {
21 | /**
22 | * @var array
23 | */
24 | public readonly array $conditions;
25 |
26 | /**
27 | * @param EqualCondition|GreaterThanCondition|GreaterThanEqualCondition|IdentifierCondition|LessThanCondition|LessThanEqualCondition|NotEqualCondition|AndCondition|OrCondition $conditions
28 | */
29 | public function __construct(...$conditions)
30 | {
31 | $this->conditions = $conditions;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Search/Condition/AndCondition.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search\Condition;
15 |
16 | class AndCondition extends AbstractGroupCondition
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/src/Search/Condition/EqualCondition.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search\Condition;
15 |
16 | class EqualCondition
17 | {
18 | public function __construct(
19 | public readonly string $field,
20 | public readonly string|int|float|bool $value,
21 | ) {
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Search/Condition/GeoBoundingBoxCondition.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search\Condition;
15 |
16 | class GeoBoundingBoxCondition
17 | {
18 | /**
19 | * The order may first be unusually, but it is the same as in common JS libraries like.
20 | *
21 | * @see https://docs.mapbox.com/help/glossary/bounding-box/
22 | * @see https://developers.google.com/maps/documentation/javascript/reference/coordinates#LatLngBounds
23 | */
24 | public function __construct(
25 | public readonly string $field,
26 | public readonly float $northLatitude, // top
27 | public readonly float $eastLongitude, // right
28 | public readonly float $southLatitude, // bottom
29 | public readonly float $westLongitude, // left
30 | ) {
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/Search/Condition/GeoDistanceCondition.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search\Condition;
15 |
16 | class GeoDistanceCondition
17 | {
18 | /**
19 | * @param int $distance search radius in meters
20 | */
21 | public function __construct(
22 | public readonly string $field,
23 | public readonly float $latitude,
24 | public readonly float $longitude,
25 | public readonly int $distance,
26 | ) {
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/Search/Condition/GreaterThanCondition.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search\Condition;
15 |
16 | class GreaterThanCondition
17 | {
18 | public function __construct(
19 | public readonly string $field,
20 | public readonly string|int|float|bool $value,
21 | ) {
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Search/Condition/GreaterThanEqualCondition.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search\Condition;
15 |
16 | class GreaterThanEqualCondition
17 | {
18 | public function __construct(
19 | public readonly string $field,
20 | public readonly string|int|float|bool $value,
21 | ) {
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Search/Condition/IdentifierCondition.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search\Condition;
15 |
16 | class IdentifierCondition
17 | {
18 | public function __construct(
19 | public readonly string $identifier,
20 | ) {
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/Search/Condition/InCondition.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search\Condition;
15 |
16 | class InCondition
17 | {
18 | /**
19 | * @param list $values
20 | */
21 | public function __construct(
22 | public readonly string $field,
23 | public readonly array $values,
24 | ) {
25 | }
26 |
27 | /**
28 | * @internal This method is for internal use and should not be called from outside.
29 | *
30 | * Some search engines do not support the `IN` operator, so we need to convert it to an `OR` condition.
31 | */
32 | public function createOrCondition(): OrCondition
33 | {
34 | /** @var array $conditions */
35 | $conditions = [];
36 | foreach ($this->values as $value) {
37 | $conditions[] = new EqualCondition($this->field, $value);
38 | }
39 |
40 | return new OrCondition(...$conditions);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/Search/Condition/LessThanCondition.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search\Condition;
15 |
16 | class LessThanCondition
17 | {
18 | public function __construct(
19 | public readonly string $field,
20 | public readonly string|int|float|bool $value,
21 | ) {
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Search/Condition/LessThanEqualCondition.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search\Condition;
15 |
16 | class LessThanEqualCondition
17 | {
18 | public function __construct(
19 | public readonly string $field,
20 | public readonly string|int|float|bool $value,
21 | ) {
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Search/Condition/NotEqualCondition.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search\Condition;
15 |
16 | class NotEqualCondition
17 | {
18 | public function __construct(
19 | public readonly string $field,
20 | public readonly string|int|float|bool $value,
21 | ) {
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Search/Condition/NotInCondition.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search\Condition;
15 |
16 | class NotInCondition
17 | {
18 | /**
19 | * @param list $values
20 | */
21 | public function __construct(
22 | public readonly string $field,
23 | public readonly array $values,
24 | ) {
25 | }
26 |
27 | /**
28 | * @internal This method is for internal use and should not be called from outside.
29 | *
30 | * Some search engines do not support the `NOT IN` operator, so we need to convert it to an `AND` condition.
31 | */
32 | public function createAndCondition(): AndCondition
33 | {
34 | /** @var array $conditions */
35 | $conditions = [];
36 | foreach ($this->values as $value) {
37 | $conditions[] = new NotEqualCondition($this->field, $value);
38 | }
39 |
40 | return new AndCondition(...$conditions);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/Search/Condition/OrCondition.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search\Condition;
15 |
16 | class OrCondition extends AbstractGroupCondition
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/src/Search/Condition/SearchCondition.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search\Condition;
15 |
16 | class SearchCondition
17 | {
18 | public function __construct(
19 | public readonly string $query,
20 | ) {
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/Search/Result.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search;
15 |
16 | /**
17 | * @extends \IteratorIterator, \Generator>
18 | */
19 | final class Result extends \IteratorIterator
20 | {
21 | /**
22 | * @param \Generator> $documents
23 | */
24 | public function __construct(
25 | \Generator $documents,
26 | readonly private int $total,
27 | ) {
28 | parent::__construct($documents);
29 | }
30 |
31 | public function total(): int
32 | {
33 | return $this->total;
34 | }
35 |
36 | public static function createEmpty(): static
37 | {
38 | return new self((static function (): \Generator {
39 | yield from [];
40 | })(), 0);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/Search/Search.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search;
15 |
16 | use CmsIg\Seal\Schema\Index;
17 |
18 | final class Search
19 | {
20 | /**
21 | * @param object[] $filters
22 | * @param array $sortBys
23 | * @param array $highlightFields
24 | */
25 | public function __construct(
26 | public readonly Index $index,
27 | public readonly array $filters = [],
28 | public readonly array $sortBys = [],
29 | public readonly int|null $limit = null,
30 | public readonly int $offset = 0,
31 | public readonly array $highlightFields = [],
32 | public readonly string $highlightPreTag = '',
33 | public readonly string $highlightPostTag = '',
34 | ) {
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Search/SearchBuilder.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Search;
15 |
16 | use CmsIg\Seal\Adapter\SearcherInterface;
17 | use CmsIg\Seal\Schema\Index;
18 | use CmsIg\Seal\Schema\Schema;
19 |
20 | final class SearchBuilder
21 | {
22 | private Index $index;
23 |
24 | /**
25 | * @var object[]
26 | */
27 | private array $filters = [];
28 |
29 | /**
30 | * @var array
31 | */
32 | private array $sortBys = [];
33 |
34 | private int $offset = 0;
35 |
36 | private int|null $limit = null;
37 |
38 | /**
39 | * @var array
40 | */
41 | private array $highlightFields = [];
42 |
43 | private string $highlightPreTag = '';
44 |
45 | private string $highlightPostTag = '';
46 |
47 | public function __construct(
48 | readonly private Schema $schema,
49 | readonly private SearcherInterface $searcher,
50 | ) {
51 | }
52 |
53 | public function index(string $name): static
54 | {
55 | $this->index = $this->schema->indexes[$name];
56 |
57 | return $this;
58 | }
59 |
60 | public function addFilter(object $filter): static
61 | {
62 | $this->filters[] = $filter;
63 |
64 | return $this;
65 | }
66 |
67 | /**
68 | * @param 'asc'|'desc' $direction
69 | */
70 | public function addSortBy(string $field, string $direction): static
71 | {
72 | $this->sortBys[$field] = $direction;
73 |
74 | return $this;
75 | }
76 |
77 | public function limit(int $limit): static
78 | {
79 | $this->limit = $limit;
80 |
81 | return $this;
82 | }
83 |
84 | public function offset(int $offset): static
85 | {
86 | $this->offset = $offset;
87 |
88 | return $this;
89 | }
90 |
91 | /**
92 | * @param array $fields
93 | */
94 | public function highlight(array $fields, string $preTag = '', string $postTag = ''): static
95 | {
96 | $this->highlightFields = $fields;
97 | $this->highlightPreTag = $preTag;
98 | $this->highlightPostTag = $postTag;
99 |
100 | return $this;
101 | }
102 |
103 | public function getSearcher(): SearcherInterface
104 | {
105 | return $this->searcher;
106 | }
107 |
108 | public function getSearch(): Search
109 | {
110 | return new Search(
111 | $this->index,
112 | $this->filters,
113 | $this->sortBys,
114 | $this->limit,
115 | $this->offset,
116 | $this->highlightFields,
117 | $this->highlightPreTag,
118 | $this->highlightPostTag,
119 | );
120 | }
121 |
122 | public function getResult(): Result
123 | {
124 | return $this->searcher->search($this->getSearch());
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/Task/AsyncTask.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Task;
15 |
16 | /**
17 | * A Task object for asynchronous tasks.
18 | *
19 | * As example Algolia returns us just a task id, which we can use to wait for the task to finish.
20 | *
21 | * @template-covariant T of mixed
22 | *
23 | * @template-implements TaskInterface
24 | */
25 | final class AsyncTask implements TaskInterface
26 | {
27 | /**
28 | * @param \Closure(): T $callback
29 | */
30 | public function __construct(
31 | private readonly \Closure $callback,
32 | ) {
33 | // TODO check if async library (e.g. react-php) should call callback method already here
34 | // for Agolia this is currently not required or possible as they use a blocking usleep
35 | // see https://github.com/algolia/algoliasearch-client-php/issues/712
36 | // but maybe for other adapters make sense to async resolve it here
37 | }
38 |
39 | public function wait(): mixed
40 | {
41 | return ($this->callback)();
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Task/MultiTask.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Task;
15 |
16 | /**
17 | * A Task object which waits for multiple tasks.
18 | *
19 | * @template-implements TaskInterface
20 | */
21 | final class MultiTask implements TaskInterface
22 | {
23 | /**
24 | * @param TaskInterface[] $tasks
25 | */
26 | public function __construct(
27 | private readonly array $tasks,
28 | ) {
29 | }
30 |
31 | /**
32 | * @return null
33 | */
34 | public function wait(): mixed
35 | {
36 | foreach ($this->tasks as $task) {
37 | $task->wait();
38 | }
39 |
40 | return null;
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/Task/SyncTask.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Task;
15 |
16 | /**
17 | * An easier to use Task object for synchronous tasks.
18 | *
19 | * @template-covariant T of mixed
20 | *
21 | * @template-implements TaskInterface
22 | */
23 | final class SyncTask implements TaskInterface
24 | {
25 | /**
26 | * @param T $result
27 | */
28 | public function __construct(
29 | private readonly mixed $result,
30 | ) {
31 | }
32 |
33 | public function wait(): mixed
34 | {
35 | return $this->result;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Task/TaskInterface.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Task;
15 |
16 | /**
17 | * @template-covariant T of mixed
18 | */
19 | interface TaskInterface
20 | {
21 | /**
22 | * In most cases for performance reasons it should be avoided to wait for a task to finish.
23 | * The search index normally correctly schedules syncs that we even don't need to wait example
24 | * that the index is created to index documents.
25 | * So the main usecase for return a task and wait for it are inside tests where index create and drops are tested.
26 | *
27 | * @return T
28 | */
29 | public function wait(): mixed;
30 | }
31 |
--------------------------------------------------------------------------------
/src/Testing/AbstractAdapterTestCase.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Testing;
15 |
16 | use CmsIg\Seal\Adapter\AdapterInterface;
17 | use CmsIg\Seal\Engine;
18 | use CmsIg\Seal\EngineInterface;
19 | use CmsIg\Seal\Exception\DocumentNotFoundException;
20 | use CmsIg\Seal\Schema\Schema;
21 | use PHPUnit\Framework\TestCase;
22 |
23 | abstract class AbstractAdapterTestCase extends TestCase
24 | {
25 | protected static AdapterInterface $adapter;
26 |
27 | protected static EngineInterface $engine;
28 |
29 | protected static Schema $schema;
30 |
31 | private static TaskHelper $taskHelper;
32 |
33 | protected function setUp(): void
34 | {
35 | self::$taskHelper = new TaskHelper();
36 | }
37 |
38 | protected static function getEngine(): EngineInterface
39 | {
40 | if (!isset(self::$engine)) {
41 | self::$schema = TestingHelper::createSchema();
42 |
43 | self::$engine = new Engine(
44 | self::$adapter,
45 | self::$schema,
46 | );
47 | }
48 |
49 | return self::$engine;
50 | }
51 |
52 | public function testIndex(): void
53 | {
54 | $engine = self::getEngine();
55 | $indexName = TestingHelper::INDEX_SIMPLE;
56 |
57 | $this->assertFalse($engine->existIndex($indexName));
58 |
59 | $task = $engine->createIndex($indexName, ['return_slow_promise_result' => true]);
60 | $task->wait();
61 |
62 | $this->assertTrue($engine->existIndex($indexName));
63 |
64 | $task = $engine->dropIndex($indexName, ['return_slow_promise_result' => true]);
65 | $task->wait();
66 |
67 | $this->assertFalse($engine->existIndex($indexName));
68 | }
69 |
70 | public function testSchema(): void
71 | {
72 | $engine = self::getEngine();
73 | $indexes = self::$schema->indexes;
74 |
75 | $task = $engine->createSchema(['return_slow_promise_result' => true]);
76 | $task->wait();
77 |
78 | foreach (\array_keys($indexes) as $index) {
79 | $this->assertTrue($engine->existIndex($index));
80 | }
81 |
82 | $task = $engine->dropSchema(['return_slow_promise_result' => true]);
83 | $task->wait();
84 |
85 | foreach (\array_keys($indexes) as $index) {
86 | $this->assertFalse($engine->existIndex($index));
87 | }
88 | }
89 |
90 | public function testDocument(): void
91 | {
92 | $engine = self::getEngine();
93 | $task = $engine->createSchema(['return_slow_promise_result' => true]);
94 | $task->wait();
95 |
96 | $documents = TestingHelper::createComplexFixtures();
97 |
98 | foreach ($documents as $document) {
99 | self::$taskHelper->tasks[] = $engine->saveDocument(TestingHelper::INDEX_COMPLEX, $document, ['return_slow_promise_result' => true]);
100 | }
101 |
102 | self::$taskHelper->waitForAll();
103 |
104 | $loadedDocuments = [];
105 | foreach ($documents as $document) {
106 | $loadedDocuments[] = $engine->getDocument(TestingHelper::INDEX_COMPLEX, $document['uuid']);
107 | }
108 |
109 | $this->assertCount(
110 | \count($documents),
111 | $loadedDocuments,
112 | );
113 |
114 | foreach ($loadedDocuments as $key => $loadedDocument) {
115 | $expectedDocument = $documents[$key];
116 |
117 | $this->assertSame($expectedDocument, $loadedDocument, 'Expected the loaded document to be the same as the saved document (' . $expectedDocument['uuid'] . ').');
118 | }
119 |
120 | foreach ($documents as $document) {
121 | self::$taskHelper->tasks[] = $engine->deleteDocument(TestingHelper::INDEX_COMPLEX, $document['uuid'], ['return_slow_promise_result' => true]);
122 | }
123 |
124 | self::$taskHelper->waitForAll();
125 |
126 | foreach ($documents as $document) {
127 | $exceptionThrown = false;
128 |
129 | try {
130 | $engine->getDocument(TestingHelper::INDEX_COMPLEX, $document['uuid']);
131 | } catch (DocumentNotFoundException) {
132 | $exceptionThrown = true;
133 | }
134 |
135 | $this->assertTrue(
136 | $exceptionThrown,
137 | 'Expected the exception "DocumentNotFoundException" to be thrown.',
138 | );
139 | }
140 | }
141 |
142 | public static function setUpBeforeClass(): void
143 | {
144 | try {
145 | $task = self::getEngine()->dropSchema(['return_slow_promise_result' => true]);
146 | $task->wait();
147 | } catch (\Exception) {
148 | // ignore eventuell not existing indexes to drop
149 | }
150 | }
151 |
152 | public static function tearDownAfterClass(): void
153 | {
154 | $task = self::getEngine()->dropSchema(['return_slow_promise_result' => true]);
155 | $task->wait();
156 | }
157 | }
158 |
--------------------------------------------------------------------------------
/src/Testing/AbstractIndexerTestCase.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Testing;
15 |
16 | use CmsIg\Seal\Adapter\AdapterInterface;
17 | use CmsIg\Seal\Adapter\IndexerInterface;
18 | use CmsIg\Seal\Adapter\SchemaManagerInterface;
19 | use CmsIg\Seal\Adapter\SearcherInterface;
20 | use CmsIg\Seal\Schema\Schema;
21 | use CmsIg\Seal\Search\Condition;
22 | use CmsIg\Seal\Search\SearchBuilder;
23 | use PHPUnit\Framework\TestCase;
24 |
25 | abstract class AbstractIndexerTestCase extends TestCase
26 | {
27 | protected static AdapterInterface $adapter;
28 |
29 | protected static SchemaManagerInterface $schemaManager;
30 |
31 | protected static IndexerInterface $indexer;
32 |
33 | protected static SearcherInterface $searcher;
34 |
35 | protected static Schema $schema;
36 |
37 | private static TaskHelper $taskHelper;
38 |
39 | protected function setUp(): void
40 | {
41 | self::$taskHelper = new TaskHelper();
42 | }
43 |
44 | public static function setUpBeforeClass(): void
45 | {
46 | self::$schemaManager = self::$adapter->getSchemaManager();
47 | self::$indexer = self::$adapter->getIndexer();
48 | self::$searcher = self::$adapter->getSearcher();
49 |
50 | self::$taskHelper = new TaskHelper();
51 | foreach (self::getSchema()->indexes as $index) {
52 | if (self::$schemaManager->existIndex($index)) {
53 | self::$schemaManager->dropIndex($index, ['return_slow_promise_result' => true])->wait();
54 | }
55 |
56 | self::$taskHelper->tasks[] = self::$schemaManager->createIndex($index, ['return_slow_promise_result' => true]);
57 | }
58 |
59 | self::$taskHelper->waitForAll();
60 | }
61 |
62 | public static function tearDownAfterClass(): void
63 | {
64 | self::$taskHelper->waitForAll();
65 |
66 | foreach (self::getSchema()->indexes as $index) {
67 | self::$taskHelper->tasks[] = self::$schemaManager->dropIndex($index, ['return_slow_promise_result' => true]);
68 | }
69 |
70 | self::$taskHelper->waitForAll();
71 | }
72 |
73 | protected static function getSchema(): Schema
74 | {
75 | if (!isset(self::$schema)) {
76 | self::$schema = TestingHelper::createSchema();
77 | }
78 |
79 | return self::$schema;
80 | }
81 |
82 | public function testSaveDeleteIdentifierCondition(): void
83 | {
84 | $documents = TestingHelper::createComplexFixtures();
85 |
86 | $schema = self::getSchema();
87 |
88 | foreach ($documents as $document) {
89 | self::$taskHelper->tasks[] = self::$indexer->save(
90 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
91 | $document,
92 | ['return_slow_promise_result' => true],
93 | );
94 | }
95 | self::$taskHelper->waitForAll();
96 |
97 | $loadedDocuments = [];
98 | foreach ($documents as $document) {
99 | $search = new SearchBuilder($schema, self::$searcher);
100 | $search->index(TestingHelper::INDEX_COMPLEX);
101 | $search->addFilter(new Condition\IdentifierCondition($document['uuid']));
102 | $search->limit(1);
103 |
104 | $resultDocument = \iterator_to_array($search->getResult(), false)[0] ?? null;
105 |
106 | if ($resultDocument) {
107 | $loadedDocuments[] = $resultDocument;
108 | }
109 | }
110 |
111 | $this->assertCount(
112 | \count($documents),
113 | $loadedDocuments,
114 | );
115 |
116 | foreach ($loadedDocuments as $key => $loadedDocument) {
117 | $expectedDocument = $documents[$key];
118 |
119 | $this->assertSame($expectedDocument, $loadedDocument);
120 | }
121 |
122 | foreach ($documents as $document) {
123 | self::$taskHelper->tasks[] = self::$indexer->delete(
124 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
125 | $document['uuid'],
126 | ['return_slow_promise_result' => true],
127 | );
128 | }
129 |
130 | self::$taskHelper->waitForAll();
131 |
132 | foreach ($documents as $document) {
133 | $search = new SearchBuilder($schema, self::$searcher);
134 | $search->index(TestingHelper::INDEX_COMPLEX);
135 | $search->addFilter(new Condition\IdentifierCondition($document['uuid']));
136 | $search->limit(1);
137 |
138 | $resultDocument = \iterator_to_array($search->getResult(), false)[0] ?? null;
139 |
140 | $this->assertNull($resultDocument, 'Expected document with uuid "' . $document['uuid'] . '" to be deleted.');
141 | }
142 | }
143 |
144 | public function testBulkSaveAndDeletion(): void
145 | {
146 | $documents = TestingHelper::createComplexFixtures();
147 |
148 | $schema = self::getSchema();
149 |
150 | $indexer = self::$indexer;
151 |
152 | self::$taskHelper->tasks[] = $indexer->bulk(
153 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
154 | $documents,
155 | [],
156 | 100,
157 | ['return_slow_promise_result' => true],
158 | );
159 |
160 | self::$taskHelper->waitForAll();
161 |
162 | $loadedDocuments = [];
163 | foreach ($documents as $document) {
164 | $search = new SearchBuilder($schema, self::$searcher);
165 | $search->index(TestingHelper::INDEX_COMPLEX);
166 | $search->addFilter(new Condition\IdentifierCondition($document['uuid']));
167 | $search->limit(1);
168 |
169 | $resultDocument = \iterator_to_array($search->getResult(), false)[0] ?? null;
170 |
171 | if ($resultDocument) {
172 | $loadedDocuments[] = $resultDocument;
173 | }
174 | }
175 |
176 | $this->assertCount(
177 | \count($documents),
178 | $loadedDocuments,
179 | );
180 |
181 | foreach ($loadedDocuments as $key => $loadedDocument) {
182 | $expectedDocument = $documents[$key];
183 |
184 | $this->assertSame($expectedDocument, $loadedDocument);
185 | }
186 |
187 | self::$taskHelper->tasks[] = $indexer->bulk(
188 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
189 | [],
190 | \array_map(
191 | static fn (array $document) => $document['uuid'],
192 | $documents,
193 | ),
194 | 100,
195 | ['return_slow_promise_result' => true],
196 | );
197 |
198 | self::$taskHelper->waitForAll();
199 |
200 | foreach ($documents as $document) {
201 | $search = new SearchBuilder($schema, self::$searcher);
202 | $search->index(TestingHelper::INDEX_COMPLEX);
203 | $search->addFilter(new Condition\IdentifierCondition($document['uuid']));
204 | $search->limit(1);
205 |
206 | $resultDocument = \iterator_to_array($search->getResult(), false)[0] ?? null;
207 |
208 | $this->assertNull($resultDocument, 'Expected document with uuid "' . $document['uuid'] . '" to be deleted.');
209 | }
210 | }
211 | }
212 |
--------------------------------------------------------------------------------
/src/Testing/AbstractSchemaManagerTestCase.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Testing;
15 |
16 | use CmsIg\Seal\Adapter\SchemaManagerInterface;
17 | use CmsIg\Seal\Schema\Schema;
18 | use PHPUnit\Framework\TestCase;
19 |
20 | abstract class AbstractSchemaManagerTestCase extends TestCase
21 | {
22 | protected static SchemaManagerInterface $schemaManager;
23 |
24 | protected Schema $schema;
25 |
26 | protected function setUp(): void
27 | {
28 | $this->schema = TestingHelper::createSchema();
29 | }
30 |
31 | public function testSimpleSchema(): void
32 | {
33 | $index = $this->schema->indexes[TestingHelper::INDEX_SIMPLE];
34 |
35 | $this->assertFalse(static::$schemaManager->existIndex($index));
36 |
37 | $task = static::$schemaManager->createIndex($index, ['return_slow_promise_result' => true]);
38 | $task->wait();
39 |
40 | $this->assertTrue(static::$schemaManager->existIndex($index));
41 |
42 | $task = static::$schemaManager->dropIndex($index, ['return_slow_promise_result' => true]);
43 | $task->wait();
44 |
45 | $this->assertFalse(static::$schemaManager->existIndex($index));
46 | }
47 |
48 | public function testComplexSchema(): void
49 | {
50 | $index = $this->schema->indexes[TestingHelper::INDEX_COMPLEX];
51 |
52 | $this->assertFalse(static::$schemaManager->existIndex($index));
53 |
54 | $task = static::$schemaManager->createIndex($index, ['return_slow_promise_result' => true]);
55 | $task->wait();
56 |
57 | $this->assertTrue(static::$schemaManager->existIndex($index));
58 |
59 | $task = static::$schemaManager->dropIndex($index, ['return_slow_promise_result' => true]);
60 | $task->wait();
61 |
62 | $this->assertFalse(static::$schemaManager->existIndex($index));
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/Testing/AbstractSearcherTestCase.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Testing;
15 |
16 | use CmsIg\Seal\Adapter\AdapterInterface;
17 | use CmsIg\Seal\Adapter\IndexerInterface;
18 | use CmsIg\Seal\Adapter\SchemaManagerInterface;
19 | use CmsIg\Seal\Adapter\SearcherInterface;
20 | use CmsIg\Seal\Schema\Schema;
21 | use CmsIg\Seal\Search\Condition;
22 | use CmsIg\Seal\Search\SearchBuilder;
23 | use PHPUnit\Framework\TestCase;
24 |
25 | abstract class AbstractSearcherTestCase extends TestCase
26 | {
27 | protected static AdapterInterface $adapter;
28 |
29 | protected static SchemaManagerInterface $schemaManager;
30 |
31 | protected static IndexerInterface $indexer;
32 |
33 | protected static SearcherInterface $searcher;
34 |
35 | protected static Schema $schema;
36 |
37 | private static TaskHelper $taskHelper;
38 |
39 | protected function setUp(): void
40 | {
41 | self::$taskHelper = new TaskHelper();
42 | }
43 |
44 | public static function setUpBeforeClass(): void
45 | {
46 | self::$schemaManager = self::$adapter->getSchemaManager();
47 | self::$indexer = self::$adapter->getIndexer();
48 | self::$searcher = self::$adapter->getSearcher();
49 |
50 | self::$taskHelper = new TaskHelper();
51 | foreach (self::getSchema()->indexes as $index) {
52 | if (self::$schemaManager->existIndex($index)) {
53 | self::$schemaManager->dropIndex($index, ['return_slow_promise_result' => true])->wait();
54 | }
55 |
56 | self::$taskHelper->tasks[] = self::$schemaManager->createIndex($index, ['return_slow_promise_result' => true]);
57 | }
58 |
59 | self::$taskHelper->waitForAll();
60 | }
61 |
62 | public static function tearDownAfterClass(): void
63 | {
64 | self::$taskHelper->waitForAll();
65 |
66 | foreach (self::getSchema()->indexes as $index) {
67 | self::$taskHelper->tasks[] = self::$schemaManager->dropIndex($index, ['return_slow_promise_result' => true]);
68 | }
69 |
70 | self::$taskHelper->waitForAll();
71 | }
72 |
73 | protected static function getSchema(): Schema
74 | {
75 | if (!isset(self::$schema)) {
76 | self::$schema = TestingHelper::createSchema();
77 | }
78 |
79 | return self::$schema;
80 | }
81 |
82 | public function testSearchCondition(): void
83 | {
84 | $documents = TestingHelper::createComplexFixtures();
85 |
86 | $schema = self::getSchema();
87 |
88 | foreach ($documents as $document) {
89 | self::$taskHelper->tasks[] = self::$indexer->save(
90 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
91 | $document,
92 | ['return_slow_promise_result' => true],
93 | );
94 | }
95 | self::$taskHelper->waitForAll();
96 |
97 | $search = new SearchBuilder($schema, self::$searcher);
98 | $search->index(TestingHelper::INDEX_COMPLEX);
99 | $search->addFilter(new Condition\SearchCondition('Blog'));
100 |
101 | $expectedDocumentsVariantA = [
102 | $documents[0],
103 | $documents[1],
104 | ];
105 | $expectedDocumentsVariantB = [
106 | $documents[1],
107 | $documents[0],
108 | ];
109 |
110 | $loadedDocuments = [...$search->getResult()];
111 | $this->assertCount(2, $loadedDocuments);
112 |
113 | $this->assertTrue(
114 | $expectedDocumentsVariantA === $loadedDocuments
115 | || $expectedDocumentsVariantB === $loadedDocuments,
116 | 'Not correct documents where found.',
117 | );
118 |
119 | $search = new SearchBuilder($schema, self::$searcher);
120 | $search->index(TestingHelper::INDEX_COMPLEX);
121 | $search->addFilter(new Condition\SearchCondition('Thing'));
122 |
123 | $this->assertSame([$documents[2]], [...$search->getResult()]);
124 |
125 | foreach ($documents as $document) {
126 | self::$taskHelper->tasks[] = self::$indexer->delete(
127 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
128 | $document['uuid'],
129 | ['return_slow_promise_result' => true],
130 | );
131 | }
132 | }
133 |
134 | public function testSearchConditionWithHighlight(): void
135 | {
136 | $documents = TestingHelper::createComplexFixtures();
137 |
138 | $schema = self::getSchema();
139 |
140 | foreach ($documents as $document) {
141 | self::$taskHelper->tasks[] = self::$indexer->save(
142 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
143 | $document,
144 | ['return_slow_promise_result' => true],
145 | );
146 | }
147 | self::$taskHelper->waitForAll();
148 |
149 | $search = new SearchBuilder($schema, self::$searcher);
150 | $search->index(TestingHelper::INDEX_COMPLEX);
151 | $search->addFilter(new Condition\SearchCondition('Blog'));
152 | $search->highlight(['title', 'article'], '', '');
153 |
154 | $expectedDocumentA = $documents[0];
155 | $expectedDocumentA['_formatted']['title'] = \str_replace(
156 | 'Blog',
157 | 'Blog',
158 | $expectedDocumentA['title'] ?? '',
159 | );
160 | $expectedDocumentA['_formatted']['article'] = null; // normalize the highlight behaviour none matches returned as null for every engine
161 | $expectedDocumentB = $documents[1];
162 | $expectedDocumentB['_formatted']['title'] = \str_replace(
163 | 'Blog',
164 | 'Blog',
165 | $expectedDocumentB['title'] ?? '',
166 | );
167 | $expectedDocumentB['_formatted']['article'] = null; // normalize the highlight behaviour none matches returned as null for every engine
168 |
169 | $expectedDocumentsVariantA = [
170 | $expectedDocumentA,
171 | $expectedDocumentB,
172 | ];
173 | $expectedDocumentsVariantB = [
174 | $expectedDocumentB,
175 | $expectedDocumentA,
176 | ];
177 |
178 | $loadedDocuments = [...$search->getResult()];
179 |
180 | $this->assertCount(2, $loadedDocuments);
181 |
182 | $this->assertTrue(
183 | $expectedDocumentsVariantA === $loadedDocuments
184 | || $expectedDocumentsVariantB === $loadedDocuments,
185 | 'Not correct documents where found.',
186 | );
187 |
188 | $search = new SearchBuilder($schema, self::$searcher);
189 | $search->index(TestingHelper::INDEX_COMPLEX);
190 | $search->addFilter(new Condition\SearchCondition('Thing'));
191 |
192 | $this->assertSame([$documents[2]], [...$search->getResult()]);
193 |
194 | foreach ($documents as $document) {
195 | self::$taskHelper->tasks[] = self::$indexer->delete(
196 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
197 | $document['uuid'],
198 | ['return_slow_promise_result' => true],
199 | );
200 | }
201 | }
202 |
203 | public function testNoneSearchableFields(): void
204 | {
205 | $documents = TestingHelper::createComplexFixtures();
206 |
207 | $schema = self::getSchema();
208 |
209 | foreach ($documents as $document) {
210 | self::$taskHelper->tasks[] = self::$indexer->save(
211 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
212 | $document,
213 | ['return_slow_promise_result' => true],
214 | );
215 | }
216 | self::$taskHelper->waitForAll();
217 |
218 | $search = new SearchBuilder($schema, self::$searcher);
219 | $search->index(TestingHelper::INDEX_COMPLEX);
220 | $search->addFilter(new Condition\SearchCondition('admin.nonesearchablefield@localhost'));
221 |
222 | $this->assertCount(0, [...$search->getResult()]);
223 | }
224 |
225 | public function testLimitAndOffset(): void
226 | {
227 | $documents = TestingHelper::createComplexFixtures();
228 |
229 | $schema = self::getSchema();
230 |
231 | foreach ($documents as $document) {
232 | self::$taskHelper->tasks[] = self::$indexer->save(
233 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
234 | $document,
235 | ['return_slow_promise_result' => true],
236 | );
237 | }
238 | self::$taskHelper->waitForAll();
239 |
240 | $search = (new SearchBuilder($schema, self::$searcher))
241 | ->index(TestingHelper::INDEX_COMPLEX)
242 | ->addFilter(new Condition\SearchCondition('Blog'))
243 | ->limit(1);
244 |
245 | $loadedDocuments = [...$search->getResult()];
246 | $this->assertCount(1, $loadedDocuments);
247 |
248 | $this->assertTrue(
249 | [$documents[0]] === $loadedDocuments
250 | || [$documents[1]] === $loadedDocuments,
251 | 'Not correct documents where found.',
252 | );
253 |
254 | $isFirstDocumentOnPage1 = [$documents[0]] === $loadedDocuments;
255 |
256 | $search = (new SearchBuilder($schema, self::$searcher))
257 | ->index(TestingHelper::INDEX_COMPLEX)
258 | ->addFilter(new Condition\SearchCondition('Blog'))
259 | ->offset(1)
260 | ->limit(1);
261 |
262 | $loadedDocuments = [...$search->getResult()];
263 | $this->assertCount(1, $loadedDocuments);
264 | $this->assertSame(
265 | $isFirstDocumentOnPage1 ? [$documents[1]] : [$documents[0]],
266 | $loadedDocuments,
267 | );
268 |
269 | foreach ($documents as $document) {
270 | self::$taskHelper->tasks[] = self::$indexer->delete(
271 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
272 | $document['uuid'],
273 | ['return_slow_promise_result' => true],
274 | );
275 | }
276 | }
277 |
278 | public function testEqualCondition(): void
279 | {
280 | $documents = TestingHelper::createComplexFixtures();
281 |
282 | $schema = self::getSchema();
283 |
284 | foreach ($documents as $document) {
285 | self::$taskHelper->tasks[] = self::$indexer->save(
286 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
287 | $document,
288 | ['return_slow_promise_result' => true],
289 | );
290 | }
291 | self::$taskHelper->waitForAll();
292 |
293 | $search = new SearchBuilder($schema, self::$searcher);
294 | $search->index(TestingHelper::INDEX_COMPLEX);
295 | $search->addFilter(new Condition\EqualCondition('tags', 'UI'));
296 |
297 | $expectedDocumentsVariantA = [
298 | $documents[0],
299 | $documents[1],
300 | ];
301 | $expectedDocumentsVariantB = [
302 | $documents[1],
303 | $documents[0],
304 | ];
305 |
306 | $loadedDocuments = [...$search->getResult()];
307 | $this->assertCount(2, $loadedDocuments);
308 |
309 | $this->assertTrue(
310 | $expectedDocumentsVariantA === $loadedDocuments
311 | || $expectedDocumentsVariantB === $loadedDocuments,
312 | 'Not correct documents where found.',
313 | );
314 |
315 | foreach ($documents as $document) {
316 | self::$taskHelper->tasks[] = self::$indexer->delete(
317 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
318 | $document['uuid'],
319 | ['return_slow_promise_result' => true],
320 | );
321 | }
322 | }
323 |
324 | public function testEqualConditionWithBoolean(): void
325 | {
326 | $documents = TestingHelper::createComplexFixtures();
327 |
328 | $schema = self::getSchema();
329 |
330 | foreach ($documents as $document) {
331 | self::$taskHelper->tasks[] = self::$indexer->save(
332 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
333 | $document,
334 | ['return_slow_promise_result' => true],
335 | );
336 | }
337 | self::$taskHelper->waitForAll();
338 |
339 | $search = new SearchBuilder($schema, self::$searcher);
340 | $search->index(TestingHelper::INDEX_COMPLEX);
341 | $search->addFilter(new Condition\EqualCondition('isSpecial', true));
342 |
343 | $expectedDocumentsVariantA = [
344 | $documents[0],
345 | ];
346 | $expectedDocumentsVariantB = [
347 | $documents[0],
348 | ];
349 |
350 | $loadedDocuments = [...$search->getResult()];
351 | $this->assertCount(1, $loadedDocuments);
352 |
353 | $this->assertTrue(
354 | $expectedDocumentsVariantA === $loadedDocuments
355 | || $expectedDocumentsVariantB === $loadedDocuments,
356 | 'Not correct documents where found.',
357 | );
358 |
359 | foreach ($documents as $document) {
360 | self::$taskHelper->tasks[] = self::$indexer->delete(
361 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
362 | $document['uuid'],
363 | ['return_slow_promise_result' => true],
364 | );
365 | }
366 | }
367 |
368 | public function testEqualConditionSpecialString(string $specialString = "^The 17\" O'Conner && O`Series \n OR a || 1%2 1~2 1*2 \r\n book? \r \twhat \\ text: }{ )( ][ - + // \n\r ok? end$"): void
369 | {
370 | $documents = TestingHelper::createComplexFixtures();
371 |
372 | $schema = self::getSchema();
373 |
374 | foreach ($documents as $key => $document) {
375 | if ('79848403-c1a1-4420-bcc2-06ed537e0d4d' === $document['uuid']) {
376 | $document['tags'][] = $specialString;
377 | }
378 |
379 | self::$taskHelper->tasks[] = self::$indexer->save(
380 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
381 | $document,
382 | ['return_slow_promise_result' => true],
383 | );
384 |
385 | $documents[$key] = $document;
386 | }
387 | self::$taskHelper->waitForAll();
388 |
389 | $search = new SearchBuilder($schema, self::$searcher);
390 | $search->index(TestingHelper::INDEX_COMPLEX);
391 | $search->addFilter(new Condition\EqualCondition('tags', $specialString));
392 |
393 | $expectedDocumentsVariantA = [
394 | $documents[1],
395 | ];
396 | $expectedDocumentsVariantB = [
397 | $documents[1],
398 | ];
399 |
400 | $loadedDocuments = [...$search->getResult()];
401 | $this->assertCount(1, $loadedDocuments);
402 |
403 | $this->assertTrue(
404 | $expectedDocumentsVariantA === $loadedDocuments
405 | || $expectedDocumentsVariantB === $loadedDocuments,
406 | 'Not correct documents where found.',
407 | );
408 |
409 | foreach ($documents as $document) {
410 | self::$taskHelper->tasks[] = self::$indexer->delete(
411 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
412 | $document['uuid'],
413 | ['return_slow_promise_result' => true],
414 | );
415 | }
416 | }
417 |
418 | public function testMultiEqualCondition(): void
419 | {
420 | $documents = TestingHelper::createComplexFixtures();
421 |
422 | $schema = self::getSchema();
423 |
424 | foreach ($documents as $document) {
425 | self::$taskHelper->tasks[] = self::$indexer->save(
426 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
427 | $document,
428 | ['return_slow_promise_result' => true],
429 | );
430 | }
431 | self::$taskHelper->waitForAll();
432 |
433 | $search = new SearchBuilder($schema, self::$searcher);
434 | $search->index(TestingHelper::INDEX_COMPLEX);
435 | $search->addFilter(new Condition\EqualCondition('tags', 'UI'));
436 | $search->addFilter(new Condition\EqualCondition('tags', 'UX'));
437 |
438 | $loadedDocuments = [...$search->getResult()];
439 | $this->assertCount(1, $loadedDocuments);
440 |
441 | $this->assertSame(
442 | [$documents[1]],
443 | $loadedDocuments,
444 | );
445 |
446 | foreach ($documents as $document) {
447 | self::$taskHelper->tasks[] = self::$indexer->delete(
448 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
449 | $document['uuid'],
450 | ['return_slow_promise_result' => true],
451 | );
452 | }
453 | }
454 |
455 | public function testEqualConditionWithSearchCondition(): void
456 | {
457 | $documents = TestingHelper::createComplexFixtures();
458 |
459 | $schema = self::getSchema();
460 |
461 | foreach ($documents as $document) {
462 | self::$taskHelper->tasks[] = self::$indexer->save(
463 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
464 | $document,
465 | ['return_slow_promise_result' => true],
466 | );
467 | }
468 | self::$taskHelper->waitForAll();
469 |
470 | $search = new SearchBuilder($schema, self::$searcher);
471 | $search->index(TestingHelper::INDEX_COMPLEX);
472 | $search->addFilter(new Condition\EqualCondition('tags', 'Tech'));
473 | $search->addFilter(new Condition\SearchCondition('Blog'));
474 |
475 | $loadedDocuments = [...$search->getResult()];
476 | $this->assertCount(1, $loadedDocuments);
477 |
478 | $this->assertSame([$documents[0]], $loadedDocuments, 'Not correct documents where found.');
479 |
480 | foreach ($documents as $document) {
481 | self::$taskHelper->tasks[] = self::$indexer->delete(
482 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
483 | $document['uuid'],
484 | ['return_slow_promise_result' => true],
485 | );
486 | }
487 | }
488 |
489 | public function testNotEqualCondition(): void
490 | {
491 | $documents = TestingHelper::createComplexFixtures();
492 |
493 | $schema = self::getSchema();
494 |
495 | foreach ($documents as $document) {
496 | self::$taskHelper->tasks[] = self::$indexer->save(
497 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
498 | $document,
499 | ['return_slow_promise_result' => true],
500 | );
501 | }
502 | self::$taskHelper->waitForAll();
503 |
504 | $search = new SearchBuilder($schema, self::$searcher);
505 | $search->index(TestingHelper::INDEX_COMPLEX);
506 | $search->addFilter(new Condition\NotEqualCondition('tags', 'UI'));
507 |
508 | $expectedDocumentsVariantA = [
509 | $documents[2],
510 | $documents[3],
511 | ];
512 | $expectedDocumentsVariantB = [
513 | $documents[3],
514 | $documents[2],
515 | ];
516 |
517 | $loadedDocuments = [...$search->getResult()];
518 | $this->assertCount(2, $loadedDocuments);
519 |
520 | $this->assertTrue(
521 | $expectedDocumentsVariantA === $loadedDocuments
522 | || $expectedDocumentsVariantB === $loadedDocuments,
523 | 'Not correct documents where found.',
524 | );
525 |
526 | foreach ($documents as $document) {
527 | self::$taskHelper->tasks[] = self::$indexer->delete(
528 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
529 | $document['uuid'],
530 | ['return_slow_promise_result' => true],
531 | );
532 | }
533 | }
534 |
535 | public function testGreaterThanCondition(): void
536 | {
537 | $documents = TestingHelper::createComplexFixtures();
538 |
539 | $schema = self::getSchema();
540 |
541 | foreach ($documents as $document) {
542 | self::$taskHelper->tasks[] = self::$indexer->save(
543 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
544 | $document,
545 | ['return_slow_promise_result' => true],
546 | );
547 | }
548 | self::$taskHelper->waitForAll();
549 |
550 | $search = new SearchBuilder($schema, self::$searcher);
551 | $search->index(TestingHelper::INDEX_COMPLEX);
552 | $search->addFilter(new Condition\GreaterThanCondition('rating', 2.5));
553 |
554 | $loadedDocuments = [...$search->getResult()];
555 | $this->assertGreaterThanOrEqual(1, \count($loadedDocuments));
556 |
557 | foreach ($loadedDocuments as $loadedDocument) {
558 | $this->assertGreaterThan(2.5, $loadedDocument['rating']);
559 | }
560 |
561 | foreach ($documents as $document) {
562 | self::$taskHelper->tasks[] = self::$indexer->delete(
563 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
564 | $document['uuid'],
565 | ['return_slow_promise_result' => true],
566 | );
567 | }
568 | }
569 |
570 | public function testGreaterThanEqualCondition(): void
571 | {
572 | $documents = TestingHelper::createComplexFixtures();
573 |
574 | $schema = self::getSchema();
575 |
576 | foreach ($documents as $document) {
577 | self::$taskHelper->tasks[] = self::$indexer->save(
578 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
579 | $document,
580 | ['return_slow_promise_result' => true],
581 | );
582 | }
583 | self::$taskHelper->waitForAll();
584 |
585 | $search = new SearchBuilder($schema, self::$searcher);
586 | $search->index(TestingHelper::INDEX_COMPLEX);
587 | $search->addFilter(new Condition\GreaterThanEqualCondition('rating', 2.5));
588 |
589 | $loadedDocuments = [...$search->getResult()];
590 | $this->assertGreaterThan(1, \count($loadedDocuments));
591 |
592 | foreach ($loadedDocuments as $loadedDocument) {
593 | $this->assertNotNull(
594 | $loadedDocument['rating'] ?? null,
595 | 'Expected only documents with rating document "' . $loadedDocument['uuid'] . '" without rating returned.',
596 | );
597 |
598 | $this->assertGreaterThanOrEqual(2.5, $loadedDocument['rating']);
599 | }
600 |
601 | foreach ($documents as $document) {
602 | self::$taskHelper->tasks[] = self::$indexer->delete(
603 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
604 | $document['uuid'],
605 | ['return_slow_promise_result' => true],
606 | );
607 | }
608 | }
609 |
610 | public function testGreaterThanEqualConditionMultiValue(): void
611 | {
612 | $documents = TestingHelper::createComplexFixtures();
613 |
614 | $schema = self::getSchema();
615 |
616 | foreach ($documents as $document) {
617 | self::$taskHelper->tasks[] = self::$indexer->save(
618 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
619 | $document,
620 | ['return_slow_promise_result' => true],
621 | );
622 | }
623 | self::$taskHelper->waitForAll();
624 |
625 | $search = new SearchBuilder($schema, self::$searcher);
626 | $search->index(TestingHelper::INDEX_COMPLEX);
627 | $search->addFilter(new Condition\GreaterThanEqualCondition('categoryIds', 3.0));
628 |
629 | $loadedDocuments = [...$search->getResult()];
630 | $this->assertCount(2, $loadedDocuments);
631 |
632 | foreach ($loadedDocuments as $loadedDocument) {
633 | /** @var int[] $categoryIds */
634 | $categoryIds = $loadedDocument['categoryIds'];
635 | $biggestCategoryId = \array_reduce($categoryIds, fn (int|null $categoryId, int|null $item): int|null => \max($categoryId, $item));
636 |
637 | $this->assertNotNull($biggestCategoryId);
638 | $this->assertGreaterThanOrEqual(3.0, $biggestCategoryId);
639 | }
640 |
641 | foreach ($documents as $document) {
642 | self::$taskHelper->tasks[] = self::$indexer->delete(
643 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
644 | $document['uuid'],
645 | ['return_slow_promise_result' => true],
646 | );
647 | }
648 | }
649 |
650 | public function testLessThanCondition(): void
651 | {
652 | $documents = TestingHelper::createComplexFixtures();
653 |
654 | $schema = self::getSchema();
655 |
656 | foreach ($documents as $document) {
657 | self::$taskHelper->tasks[] = self::$indexer->save(
658 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
659 | $document,
660 | ['return_slow_promise_result' => true],
661 | );
662 | }
663 | self::$taskHelper->waitForAll();
664 |
665 | $search = new SearchBuilder($schema, self::$searcher);
666 | $search->index(TestingHelper::INDEX_COMPLEX);
667 | $search->addFilter(new Condition\LessThanCondition('rating', 3.5));
668 |
669 | $loadedDocuments = [...$search->getResult()];
670 | $this->assertGreaterThanOrEqual(1, \count($loadedDocuments));
671 |
672 | foreach ($loadedDocuments as $loadedDocument) {
673 | $this->assertNotNull(
674 | $loadedDocument['rating'] ?? null,
675 | 'Expected only documents with rating document "' . $loadedDocument['uuid'] . '" without rating returned.',
676 | );
677 |
678 | $this->assertLessThan(3.5, $loadedDocument['rating']);
679 | }
680 |
681 | foreach ($documents as $document) {
682 | self::$taskHelper->tasks[] = self::$indexer->delete(
683 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
684 | $document['uuid'],
685 | ['return_slow_promise_result' => true],
686 | );
687 | }
688 | }
689 |
690 | public function testLessThanEqualCondition(): void
691 | {
692 | $documents = TestingHelper::createComplexFixtures();
693 |
694 | $schema = self::getSchema();
695 |
696 | foreach ($documents as $document) {
697 | self::$taskHelper->tasks[] = self::$indexer->save(
698 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
699 | $document,
700 | ['return_slow_promise_result' => true],
701 | );
702 | }
703 | self::$taskHelper->waitForAll();
704 |
705 | $search = new SearchBuilder($schema, self::$searcher);
706 | $search->index(TestingHelper::INDEX_COMPLEX);
707 | $search->addFilter(new Condition\LessThanEqualCondition('rating', 3.5));
708 |
709 | $loadedDocuments = [...$search->getResult()];
710 | $this->assertGreaterThan(1, \count($loadedDocuments));
711 |
712 | foreach ($loadedDocuments as $loadedDocument) {
713 | $this->assertNotNull(
714 | $loadedDocument['rating'] ?? null,
715 | 'Expected only documents with rating document "' . $loadedDocument['uuid'] . '" without rating returned.',
716 | );
717 |
718 | $this->assertLessThanOrEqual(3.5, $loadedDocument['rating']);
719 | }
720 |
721 | foreach ($documents as $document) {
722 | self::$taskHelper->tasks[] = self::$indexer->delete(
723 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
724 | $document['uuid'],
725 | ['return_slow_promise_result' => true],
726 | );
727 | }
728 | }
729 |
730 | public function testGeoDistanceCondition(): void
731 | {
732 | $documents = TestingHelper::createComplexFixtures();
733 |
734 | $schema = self::getSchema();
735 |
736 | foreach ($documents as $document) {
737 | self::$taskHelper->tasks[] = self::$indexer->save(
738 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
739 | $document,
740 | ['return_slow_promise_result' => true],
741 | );
742 | }
743 | self::$taskHelper->waitForAll();
744 |
745 | $search = new SearchBuilder($schema, self::$searcher);
746 | $search->index(TestingHelper::INDEX_COMPLEX);
747 | $search->addFilter(new Condition\GeoDistanceCondition(
748 | 'location',
749 | // Berlin
750 | 52.5200,
751 | 13.4050,
752 | 1_000_000, // 1000 km
753 | ));
754 |
755 | $loadedDocuments = [...$search->getResult()];
756 | $this->assertGreaterThan(1, \count($loadedDocuments));
757 |
758 | foreach ($loadedDocuments as $loadedDocument) {
759 | $this->assertNotNull(
760 | $loadedDocument['location'] ?? null,
761 | 'Expected only documents with location document "' . $loadedDocument['uuid'] . '" without location returned.',
762 | );
763 | $this->assertIsArray($loadedDocument['location']);
764 |
765 | $latitude = $loadedDocument['location']['latitude'] ?? null;
766 | $longitude = $loadedDocument['location']['longitude'] ?? null;
767 |
768 | $this->assertNotNull(
769 | $latitude,
770 | 'Expected only documents with location document "' . $loadedDocument['uuid'] . '" without location latitude returned.',
771 | );
772 |
773 | $this->assertNotNull(
774 | $longitude,
775 | 'Expected only documents with location document "' . $loadedDocument['uuid'] . '" without location latitude returned.',
776 | );
777 |
778 | $distance = (int) (6_371_000 * 2 * \asin(\sqrt(
779 | \sin(\deg2rad($latitude - 52.5200) / 2) ** 2 +
780 | \cos(\deg2rad(52.5200)) * \cos(\deg2rad($latitude)) * \sin(\deg2rad($longitude - 13.4050) / 2) ** 2,
781 | )));
782 |
783 | $this->assertLessThanOrEqual(6_000_000, $distance);
784 | }
785 |
786 | foreach ($documents as $document) {
787 | self::$taskHelper->tasks[] = self::$indexer->delete(
788 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
789 | $document['uuid'],
790 | ['return_slow_promise_result' => true],
791 | );
792 | }
793 | }
794 |
795 | public function testGeoBoundingBoxCondition(): void
796 | {
797 | $documents = TestingHelper::createComplexFixtures();
798 |
799 | $schema = self::getSchema();
800 |
801 | foreach ($documents as $document) {
802 | self::$taskHelper->tasks[] = self::$indexer->save(
803 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
804 | $document,
805 | ['return_slow_promise_result' => true],
806 | );
807 | }
808 | self::$taskHelper->waitForAll();
809 |
810 | $search = new SearchBuilder($schema, self::$searcher);
811 | $search->index(TestingHelper::INDEX_COMPLEX);
812 | $search->addFilter(new Condition\GeoBoundingBoxCondition(
813 | 'location',
814 | // Dublin - Athen
815 | 53.3498, // top
816 | 23.7275, // right
817 | 37.9838, // bottom
818 | -6.2603, // left
819 | ));
820 |
821 | $loadedDocuments = [...$search->getResult()];
822 | $this->assertGreaterThan(1, \count($loadedDocuments));
823 |
824 | foreach ($loadedDocuments as $loadedDocument) {
825 | $this->assertNotNull(
826 | $loadedDocument['location'] ?? null,
827 | 'Expected only documents with location document "' . $loadedDocument['uuid'] . '" without location returned.',
828 | );
829 | $this->assertIsArray($loadedDocument['location']);
830 |
831 | $latitude = $loadedDocument['location']['latitude'] ?? null;
832 | $longitude = $loadedDocument['location']['longitude'] ?? null;
833 |
834 | $this->assertNotNull(
835 | $latitude,
836 | 'Expected only documents with location document "' . $loadedDocument['uuid'] . '" without location latitude returned.',
837 | );
838 |
839 | $this->assertNotNull(
840 | $longitude,
841 | 'Expected only documents with location document "' . $loadedDocument['uuid'] . '" without location latitude returned.',
842 | );
843 |
844 | $isInBoxFunction = function (
845 | float $latitude,
846 | float $longitude,
847 | float $northLatitude,
848 | float $eastLongitude,
849 | float $southLatitude,
850 | float $westLongitude,
851 | ): bool {
852 | // Check if the latitude is between the north and south boundaries
853 | $isWithinLatitude = $latitude <= $northLatitude && $latitude >= $southLatitude;
854 |
855 | // Check if the longitude is between the west and east boundaries
856 | $isWithinLongitude = $longitude >= $westLongitude && $longitude <= $eastLongitude;
857 |
858 | // The point is inside the bounding box if both conditions are true
859 | return $isWithinLatitude && $isWithinLongitude;
860 | };
861 |
862 | // TODO: Fix this test
863 | $isInBox = $isInBoxFunction($latitude, $longitude, 53.3498, 23.7275, 37.9838, -6.2603);
864 | $this->assertTrue($isInBox, 'Document "' . $loadedDocument['uuid'] . '" is not in the box.');
865 | }
866 |
867 | foreach ($documents as $document) {
868 | self::$taskHelper->tasks[] = self::$indexer->delete(
869 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
870 | $document['uuid'],
871 | ['return_slow_promise_result' => true],
872 | );
873 | }
874 | }
875 |
876 | public function testLessThanEqualConditionMultiValue(): void
877 | {
878 | $documents = TestingHelper::createComplexFixtures();
879 |
880 | $schema = self::getSchema();
881 |
882 | foreach ($documents as $document) {
883 | self::$taskHelper->tasks[] = self::$indexer->save(
884 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
885 | $document,
886 | ['return_slow_promise_result' => true],
887 | );
888 | }
889 | self::$taskHelper->waitForAll();
890 |
891 | $search = new SearchBuilder($schema, self::$searcher);
892 | $search->index(TestingHelper::INDEX_COMPLEX);
893 | $search->addFilter(new Condition\LessThanEqualCondition('categoryIds', 2.0));
894 |
895 | $loadedDocuments = [...$search->getResult()];
896 | $this->assertCount(2, $loadedDocuments);
897 |
898 | foreach ($loadedDocuments as $loadedDocument) {
899 | /** @var int[] $categoryIds */
900 | $categoryIds = $loadedDocument['categoryIds'];
901 | $smallestCategoryId = \array_reduce($categoryIds, fn (int|null $categoryId, int|null $item): int|null => null !== $categoryId ? \min($categoryId, $item) : $item);
902 |
903 | $this->assertNotNull($smallestCategoryId);
904 | $this->assertLessThanOrEqual(2.0, $smallestCategoryId);
905 | }
906 |
907 | foreach ($documents as $document) {
908 | self::$taskHelper->tasks[] = self::$indexer->delete(
909 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
910 | $document['uuid'],
911 | ['return_slow_promise_result' => true],
912 | );
913 | }
914 | }
915 |
916 | public function testInCondition(): void
917 | {
918 | $documents = TestingHelper::createComplexFixtures();
919 |
920 | $schema = self::getSchema();
921 |
922 | foreach ($documents as $document) {
923 | self::$taskHelper->tasks[] = self::$indexer->save(
924 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
925 | $document,
926 | ['return_slow_promise_result' => true],
927 | );
928 | }
929 | self::$taskHelper->waitForAll();
930 |
931 | $search = new SearchBuilder($schema, self::$searcher);
932 | $search->index(TestingHelper::INDEX_COMPLEX);
933 | $search->addFilter(new Condition\InCondition('tags', ['UI']));
934 |
935 | $expectedDocumentsVariantA = [
936 | $documents[0],
937 | $documents[1],
938 | ];
939 | $expectedDocumentsVariantB = [
940 | $documents[1],
941 | $documents[0],
942 | ];
943 |
944 | $loadedDocuments = [...$search->getResult()];
945 | $this->assertCount(2, $loadedDocuments);
946 |
947 | $this->assertTrue(
948 | $expectedDocumentsVariantA === $loadedDocuments
949 | || $expectedDocumentsVariantB === $loadedDocuments,
950 | 'Not correct documents where found.',
951 | );
952 |
953 | foreach ($documents as $document) {
954 | self::$taskHelper->tasks[] = self::$indexer->delete(
955 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
956 | $document['uuid'],
957 | ['return_slow_promise_result' => true],
958 | );
959 | }
960 | }
961 |
962 | public function testNotInCondition(): void
963 | {
964 | $documents = TestingHelper::createComplexFixtures();
965 |
966 | $schema = self::getSchema();
967 |
968 | foreach ($documents as $document) {
969 | self::$taskHelper->tasks[] = self::$indexer->save(
970 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
971 | $document,
972 | ['return_slow_promise_result' => true],
973 | );
974 | }
975 | self::$taskHelper->waitForAll();
976 |
977 | $search = new SearchBuilder($schema, self::$searcher);
978 | $search->index(TestingHelper::INDEX_COMPLEX);
979 | $search->addFilter(new Condition\NotInCondition('tags', ['UI']));
980 |
981 | $expectedDocumentsVariantA = [
982 | $documents[2],
983 | $documents[3],
984 | ];
985 |
986 | $expectedDocumentsVariantB = [
987 | $documents[3],
988 | $documents[2],
989 | ];
990 |
991 | $loadedDocuments = [...$search->getResult()];
992 | $this->assertCount(2, $loadedDocuments);
993 |
994 | $this->assertTrue(
995 | $expectedDocumentsVariantA === $loadedDocuments
996 | || $expectedDocumentsVariantB === $loadedDocuments,
997 | 'Not correct documents where found.',
998 | );
999 |
1000 | foreach ($documents as $document) {
1001 | self::$taskHelper->tasks[] = self::$indexer->delete(
1002 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
1003 | $document['uuid'],
1004 | ['return_slow_promise_result' => true],
1005 | );
1006 | }
1007 | }
1008 |
1009 | public function testSortByAsc(): void
1010 | {
1011 | $documents = TestingHelper::createComplexFixtures();
1012 |
1013 | $schema = self::getSchema();
1014 |
1015 | foreach ($documents as $document) {
1016 | self::$taskHelper->tasks[] = self::$indexer->save(
1017 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
1018 | $document,
1019 | ['return_slow_promise_result' => true],
1020 | );
1021 | }
1022 | self::$taskHelper->waitForAll();
1023 |
1024 | $search = new SearchBuilder($schema, self::$searcher);
1025 | $search->index(TestingHelper::INDEX_COMPLEX);
1026 | $search->addFilter(new Condition\GreaterThanCondition('rating', 0));
1027 | $search->addSortBy('rating', 'asc');
1028 |
1029 | $loadedDocuments = [...$search->getResult()];
1030 | $this->assertGreaterThan(1, \count($loadedDocuments));
1031 |
1032 | foreach ($documents as $document) {
1033 | self::$taskHelper->tasks[] = self::$indexer->delete(
1034 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
1035 | $document['uuid'],
1036 | ['return_slow_promise_result' => true],
1037 | );
1038 | }
1039 |
1040 | $beforeRating = 0;
1041 | foreach ($loadedDocuments as $loadedDocument) {
1042 | $rating = $loadedDocument['rating'] ?? 0;
1043 | $this->assertGreaterThanOrEqual($beforeRating, $rating);
1044 | $beforeRating = $rating;
1045 | }
1046 | }
1047 |
1048 | public function testSortByDesc(): void
1049 | {
1050 | $documents = TestingHelper::createComplexFixtures();
1051 |
1052 | $schema = self::getSchema();
1053 |
1054 | foreach ($documents as $document) {
1055 | self::$taskHelper->tasks[] = self::$indexer->save(
1056 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
1057 | $document,
1058 | ['return_slow_promise_result' => true],
1059 | );
1060 | }
1061 | self::$taskHelper->waitForAll();
1062 |
1063 | $search = new SearchBuilder($schema, self::$searcher);
1064 | $search->index(TestingHelper::INDEX_COMPLEX);
1065 | $search->addFilter(new Condition\GreaterThanCondition('rating', 0));
1066 | $search->addSortBy('rating', 'desc');
1067 |
1068 | $loadedDocuments = [...$search->getResult()];
1069 | $this->assertGreaterThan(1, \count($loadedDocuments));
1070 |
1071 | foreach ($documents as $document) {
1072 | self::$taskHelper->tasks[] = self::$indexer->delete(
1073 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
1074 | $document['uuid'],
1075 | ['return_slow_promise_result' => true],
1076 | );
1077 | }
1078 | $beforeRating = \PHP_INT_MAX;
1079 | foreach ($loadedDocuments as $loadedDocument) {
1080 | $rating = $loadedDocument['rating'] ?? 0;
1081 | $this->assertLessThanOrEqual($beforeRating, $rating);
1082 | $beforeRating = $rating;
1083 | }
1084 | }
1085 |
1086 | public function testSortByTextFieldAsc(): void
1087 | {
1088 | $documents = TestingHelper::createComplexFixtures();
1089 |
1090 | $schema = self::getSchema();
1091 |
1092 | foreach ($documents as $document) {
1093 | self::$taskHelper->tasks[] = self::$indexer->save(
1094 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
1095 | $document,
1096 | ['return_slow_promise_result' => true],
1097 | );
1098 | }
1099 | self::$taskHelper->waitForAll();
1100 |
1101 | $search = new SearchBuilder($schema, self::$searcher);
1102 | $search->index(TestingHelper::INDEX_COMPLEX);
1103 | $search->addFilter(new Condition\NotEqualCondition('uuid', '97cd3e94-c17f-4c11-a22b-d9da2e5318cd'));
1104 | $search->addSortBy('title', 'asc');
1105 |
1106 | $loadedDocuments = [...$search->getResult()];
1107 | $this->assertCount(3, $loadedDocuments);
1108 |
1109 | foreach ($documents as $document) {
1110 | self::$taskHelper->tasks[] = self::$indexer->delete(
1111 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
1112 | $document['uuid'],
1113 | ['return_slow_promise_result' => true],
1114 | );
1115 | }
1116 |
1117 | $beforeTitle = null;
1118 | foreach ($loadedDocuments as $loadedDocument) {
1119 | $title = $loadedDocument['title'] ?? '';
1120 | $this->assertSame(-1, $beforeTitle <=> $title);
1121 | $beforeTitle = $title;
1122 | }
1123 | }
1124 |
1125 | public function testSortByTextFieldDesc(): void
1126 | {
1127 | $documents = TestingHelper::createComplexFixtures();
1128 |
1129 | $schema = self::getSchema();
1130 |
1131 | foreach ($documents as $document) {
1132 | self::$taskHelper->tasks[] = self::$indexer->save(
1133 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
1134 | $document,
1135 | ['return_slow_promise_result' => true],
1136 | );
1137 | }
1138 | self::$taskHelper->waitForAll();
1139 |
1140 | $search = new SearchBuilder($schema, self::$searcher);
1141 | $search->index(TestingHelper::INDEX_COMPLEX);
1142 | $search->addFilter(new Condition\NotEqualCondition('uuid', '97cd3e94-c17f-4c11-a22b-d9da2e5318cd'));
1143 | $search->addSortBy('title', 'desc');
1144 |
1145 | $loadedDocuments = [...$search->getResult()];
1146 | $this->assertCount(3, $loadedDocuments);
1147 |
1148 | foreach ($documents as $document) {
1149 | self::$taskHelper->tasks[] = self::$indexer->delete(
1150 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
1151 | $document['uuid'],
1152 | ['return_slow_promise_result' => true],
1153 | );
1154 | }
1155 |
1156 | $beforeTitle = null;
1157 | foreach ($loadedDocuments as $loadedDocument) {
1158 | $title = $loadedDocument['title'] ?? '';
1159 | $this->assertSame(null === $beforeTitle ? -1 : 1, $beforeTitle <=> $title);
1160 | $beforeTitle = $title;
1161 | }
1162 | }
1163 |
1164 | public function testSearchingWithNestedAndOrConditions(): void
1165 | {
1166 | $expectedDocumentIds = [];
1167 | $documents = TestingHelper::createComplexFixtures();
1168 | $schema = self::getSchema();
1169 |
1170 | foreach ($documents as $document) {
1171 | self::$taskHelper->tasks[] = self::$indexer->save(
1172 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
1173 | $document,
1174 | ['return_slow_promise_result' => true],
1175 | );
1176 |
1177 | if (!isset($document['tags'])) {
1178 | continue;
1179 | }
1180 |
1181 | if (\in_array('Tech', $document['tags'], true)
1182 | && (\in_array('UX', $document['tags'], true) || (isset($document['isSpecial']) && false === $document['isSpecial']))
1183 | ) {
1184 | $expectedDocumentIds[] = $document['uuid'];
1185 | }
1186 | }
1187 | $expectedDocumentIds = \array_unique($expectedDocumentIds);
1188 |
1189 | self::$taskHelper->waitForAll();
1190 |
1191 | $search = new SearchBuilder($schema, self::$searcher);
1192 | $search->index(TestingHelper::INDEX_COMPLEX);
1193 |
1194 | $condition = new Condition\AndCondition(
1195 | new Condition\EqualCondition('tags', 'Tech'),
1196 | new Condition\OrCondition(
1197 | new Condition\EqualCondition('tags', 'UX'),
1198 | new Condition\EqualCondition('isSpecial', false),
1199 | ),
1200 | );
1201 |
1202 | $search->addFilter($condition);
1203 |
1204 | $loadedDocumentIds = \array_map(fn (array $document) => $document['uuid'], [...$search->getResult()]);
1205 |
1206 | \sort($expectedDocumentIds);
1207 | \sort($loadedDocumentIds);
1208 |
1209 | $this->assertSame($expectedDocumentIds, $loadedDocumentIds, 'Incorrect documents found.');
1210 |
1211 | foreach ($documents as $document) {
1212 | self::$taskHelper->tasks[] = self::$indexer->delete(
1213 | $schema->indexes[TestingHelper::INDEX_COMPLEX],
1214 | $document['uuid'],
1215 | ['return_slow_promise_result' => true],
1216 | );
1217 | }
1218 | }
1219 | }
1220 |
--------------------------------------------------------------------------------
/src/Testing/TaskHelper.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Testing;
15 |
16 | use CmsIg\Seal\Task\MultiTask;
17 | use CmsIg\Seal\Task\TaskInterface;
18 |
19 | /**
20 | * @internal
21 | */
22 | final class TaskHelper
23 | {
24 | /**
25 | * @var TaskInterface[]
26 | */
27 | public array $tasks = [];
28 |
29 | public function waitForAll(): void
30 | {
31 | (new MultiTask($this->tasks))->wait();
32 |
33 | $this->tasks = [];
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/Testing/TestingHelper.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * For the full copyright and license information, please view the LICENSE
11 | * file that was distributed with this source code.
12 | */
13 |
14 | namespace CmsIg\Seal\Testing;
15 |
16 | use CmsIg\Seal\Schema\Field;
17 | use CmsIg\Seal\Schema\Index;
18 | use CmsIg\Seal\Schema\Schema;
19 |
20 | final class TestingHelper
21 | {
22 | public const INDEX_COMPLEX = 'complex';
23 |
24 | public const INDEX_SIMPLE = 'simple';
25 |
26 | private function __construct()
27 | {
28 | }
29 |
30 | public static function createSchema(): Schema
31 | {
32 | $prefix = \getenv('TEST_INDEX_PREFIX') ?: $_ENV['TEST_INDEX_PREFIX'] ?? 'test_';
33 |
34 | $complexFields = [
35 | 'uuid' => new Field\IdentifierField('uuid'),
36 | 'title' => new Field\TextField('title', sortable: true),
37 | 'header' => new Field\TypedField('header', 'type', [
38 | 'image' => [
39 | 'media' => new Field\IntegerField('media'),
40 | ],
41 | 'video' => [
42 | 'media' => new Field\TextField('media', searchable: false),
43 | ],
44 | ]),
45 | 'article' => new Field\TextField('article'),
46 | 'blocks' => new Field\TypedField('blocks', 'type', [
47 | 'text' => [
48 | 'title' => new Field\TextField('title'),
49 | 'description' => new Field\TextField('description'),
50 | 'media' => new Field\IntegerField('media', multiple: true),
51 | ],
52 | 'embed' => [
53 | 'title' => new Field\TextField('title'),
54 | 'media' => new Field\TextField('media', searchable: false),
55 | ],
56 | ], multiple: true),
57 | 'footer' => new Field\ObjectField('footer', [
58 | 'title' => new Field\TextField('title'),
59 | ]),
60 | 'created' => new Field\DateTimeField('created', filterable: true, sortable: true),
61 | 'commentsCount' => new Field\IntegerField('commentsCount', filterable: true, sortable: true),
62 | 'rating' => new Field\FloatField('rating', filterable: true, sortable: true),
63 | 'isSpecial' => new Field\BooleanField('isSpecial', filterable: true),
64 | 'comments' => new Field\ObjectField('comments', [
65 | 'email' => new Field\TextField('email', searchable: false),
66 | 'text' => new Field\TextField('text'),
67 | ], multiple: true),
68 | 'tags' => new Field\TextField('tags', multiple: true, filterable: true),
69 | 'categoryIds' => new Field\IntegerField('categoryIds', multiple: true, filterable: true),
70 | 'location' => new Field\GeoPointField('location', filterable: true, sortable: true),
71 | ];
72 |
73 | $simpleFields = [
74 | 'id' => new Field\IdentifierField('id'),
75 | 'title' => new Field\TextField('title'),
76 | ];
77 |
78 | $complexIndex = new Index($prefix . 'complex', $complexFields);
79 | $simpleIndex = new Index($prefix . 'simple', $simpleFields);
80 |
81 | return new Schema([
82 | self::INDEX_COMPLEX => $complexIndex,
83 | self::INDEX_SIMPLE => $simpleIndex,
84 | ]);
85 | }
86 |
87 | /**
88 | * @return array,
102 | * created?: string|null,
103 | * commentsCount?: int|null,
104 | * rating?: float|null,
105 | * isSpecial?: bool,
106 | * comments?: array|null,
110 | * tags?: string[]|null,
111 | * categoryIds?: int[]|null,
112 | * location?: array{
113 | * latitude: float,
114 | * longitude: float,
115 | * },
116 | * }>
117 | */
118 | public static function createComplexFixtures(): array
119 | {
120 | return [
121 | [
122 | 'uuid' => '23b30f01-d8fd-4dca-b36a-4710e360a965',
123 | 'title' => 'New Blog',
124 | 'header' => [
125 | 'type' => 'image',
126 | 'media' => 1,
127 | ],
128 | 'article' => 'New Subtitle
A html field with some content
',
129 | 'blocks' => [
130 | [
131 | 'type' => 'text',
132 | 'title' => 'Title',
133 | 'description' => 'Description
',
134 | 'media' => [3, 4],
135 | ],
136 | [
137 | 'type' => 'text',
138 | 'title' => 'Title 2',
139 | ],
140 | [
141 | 'type' => 'embed',
142 | 'title' => 'Video',
143 | 'media' => 'https://www.youtube.com/watch?v=iYM2zFP3Zn0',
144 | ],
145 | [
146 | 'type' => 'text',
147 | 'title' => 'Title 4',
148 | 'description' => 'Description 4
',
149 | 'media' => [3, 4],
150 | ],
151 | ],
152 | 'footer' => [
153 | 'title' => 'New Footer',
154 | ],
155 | 'created' => '2022-01-24T12:00:00+01:00',
156 | 'commentsCount' => 2,
157 | 'rating' => 3.5,
158 | 'isSpecial' => true,
159 | 'comments' => [
160 | [
161 | 'email' => 'admin.nonesearchablefield@localhost',
162 | 'text' => 'Awesome blog!',
163 | ],
164 | [
165 | 'email' => 'example.nonesearchablefield@localhost',
166 | 'text' => 'Like this blog!',
167 | ],
168 | ],
169 | 'tags' => ['Tech', 'UI'],
170 | 'categoryIds' => [1, 2],
171 | 'location' => [
172 | // New York
173 | 'latitude' => 40.7128,
174 | 'longitude' => -74.0060,
175 | ],
176 | ],
177 | [
178 | 'uuid' => '79848403-c1a1-4420-bcc2-06ed537e0d4d',
179 | 'title' => 'Other Blog',
180 | 'header' => [
181 | 'type' => 'video',
182 | 'media' => 'https://www.youtube.com/watch?v=iYM2zFP3Zn0',
183 | ],
184 | 'article' => 'Other Subtitle
A html field with some content
',
185 | 'footer' => [
186 | 'title' => 'Other Footer',
187 | ],
188 | 'created' => '2022-12-26T12:00:00+01:00',
189 | 'commentsCount' => 0,
190 | 'rating' => 2.5,
191 | 'isSpecial' => false,
192 | 'tags' => ['UI', 'UX'],
193 | 'categoryIds' => [2, 3],
194 | 'location' => [
195 | // London
196 | 'latitude' => 51.5074,
197 | 'longitude' => -0.1278,
198 | ],
199 | ],
200 | [
201 | 'uuid' => '8d90e7d9-2b56-4980-90ce-f91d020cee53',
202 | 'title' => 'Other Thing',
203 | 'article' => 'Other Thing
A html field with some content
',
204 | 'footer' => [
205 | 'title' => 'Other Footer',
206 | ],
207 | 'created' => '2023-02-03T12:00:00+01:00',
208 | 'commentsCount' => 0,
209 | 'tags' => ['Tech', 'UX'],
210 | 'categoryIds' => [3, 4],
211 | 'location' => [
212 | // Vienna
213 | 'latitude' => 48.2082,
214 | 'longitude' => 16.3738,
215 | ],
216 | ],
217 | [
218 | 'uuid' => '97cd3e94-c17f-4c11-a22b-d9da2e5318cd',
219 | ],
220 | ];
221 | }
222 |
223 | /**
224 | * @return array
228 | */
229 | public static function createSimpleFixtures(): array
230 | {
231 | return [
232 | [
233 | 'id' => '1',
234 | 'title' => 'Simple Title',
235 | ],
236 | [
237 | 'id' => '2',
238 | 'title' => 'Other Title',
239 | ],
240 | [
241 | 'id' => '3',
242 | ],
243 | ];
244 | }
245 | }
246 |
--------------------------------------------------------------------------------