├── .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 | SEAL Logo with an abstract seal sitting on a telescope. 8 |
9 | 10 |
Logo created by Meine Wilma
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 | --------------------------------------------------------------------------------