├── pint.json
├── phpstan.neon
├── .gitignore
├── .editorconfig
├── phpunit.xml
├── src
├── Interfaces
│ ├── ClientFactoryInterface.php
│ ├── ScopeInterface.php
│ ├── ConnectionResolverInterface.php
│ └── ConnectionInterface.php
├── helpers.php
├── pagination
│ ├── simple-default.php
│ ├── simple-bootstrap-4.php
│ ├── default.php
│ └── bootstrap-4.php
├── Exceptions
│ └── DocumentNotFoundException.php
├── Factories
│ └── ClientFactory.php
├── Pagination.php
├── Request.php
├── Concerns
│ ├── ExplainsQueries.php
│ ├── ManagesIndices.php
│ ├── HasGlobalScopes.php
│ └── AppliesScopes.php
├── Commands
│ ├── DropIndexCommand.php
│ ├── ListIndicesCommand.php
│ ├── CreateIndexCommand.php
│ ├── UpdateIndexCommand.php
│ └── ReindexCommand.php
├── ConnectionResolver.php
├── Classes
│ ├── Search.php
│ └── Bulk.php
├── Facades
│ └── Elasticsearch.php
├── Collection.php
├── ConnectionManager.php
├── ScoutEngine.php
├── ElasticsearchServiceProvider.php
├── Query.php
└── Index.php
├── .github
└── dependabot.yml
├── config
├── logging.php
├── scout.php
└── elasticsearch.php
├── tests
├── Factories
│ └── ClientFactoryTest.php
├── ConnectionTest.php
├── OrderTest.php
├── ConnectionResolverTest.php
├── BodyTest.php
├── IndexTest.php
├── SkipTest.php
├── SizeTest.php
├── IgnoreTest.php
├── SelectTest.php
├── WhereInTest.php
├── WhereNotInTest.php
├── SearchTest.php
├── DistanceTest.php
├── WhereBetweenTest.php
├── WhereNotBetweenTest.php
├── Traits
│ ├── ResolvesConnections.php
│ └── ESQueryTrait.php
├── ConnectionManagerTest.php
├── WhereNotTest.php
├── WhereTest.php
└── GlobalScopeTest.php
├── LICENSE
├── psalm.xml
└── composer.json
/pint.json:
--------------------------------------------------------------------------------
1 | {
2 | "preset": "per"
3 | }
4 |
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | level: 2
3 | paths:
4 | - src/
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.idea/
2 | /vendor/
3 | .gitattributes
4 | .DS_Store
5 | .phpunit.result.cache
6 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | insert_final_newline = true
7 | indent_style = space
8 | indent_size = 4
9 | trim_trailing_whitespace = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
14 | [*.yml]
15 | indent_style = space
16 | indent_size = 2
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 | ./tests
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/Interfaces/ClientFactoryInterface.php:
--------------------------------------------------------------------------------
1 | $config
15 | *
16 | * @return Client
17 | */
18 | public function createClient(array $config): Client;
19 | }
20 |
--------------------------------------------------------------------------------
/src/Interfaces/ScopeInterface.php:
--------------------------------------------------------------------------------
1 | basePath() . '/config' . ($path ? '/' . $path : $path);
16 | }
17 | }
18 |
19 | if (!function_exists('base_path')) {
20 | /**
21 | * Get the path to the base of the install.
22 | *
23 | * @param string $path
24 | *
25 | * @return string
26 | */
27 | function base_path(string $path = ''): string
28 | {
29 | return app()->basePath() . ($path ? '/' . $path : $path);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Interfaces/ConnectionResolverInterface.php:
--------------------------------------------------------------------------------
1 | [
20 | 'elasticsearch' => [
21 | 'driver' => 'stack',
22 | 'channels' => ['stack'],
23 | 'name' => 'elasticsearch',
24 | 'ignore_exceptions' => false,
25 | 'level' => 'info',
26 | ],
27 | ],
28 | ];
29 |
--------------------------------------------------------------------------------
/tests/Factories/ClientFactoryTest.php:
--------------------------------------------------------------------------------
1 | createClient([]);
19 |
20 | self::assertInstanceOf(Client::class, $client);
21 | }
22 |
23 | public function testCreateClientWithHosts(): void
24 | {
25 | $config = [
26 | 'hosts' => ['foo', 'bar', 'baz'],
27 | ];
28 | $factory = new ClientFactory();
29 | $client = $factory->createClient($config);
30 |
31 | self::assertContains(
32 | $client->transport->getConnection()->getHost(),
33 | $config['hosts'],
34 | );
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/pagination/simple-default.php:
--------------------------------------------------------------------------------
1 | hasPages()): ?>
11 |
39 |
41 |
--------------------------------------------------------------------------------
/tests/ConnectionTest.php:
--------------------------------------------------------------------------------
1 | app->make(ConnectionInterface::class);
22 | $query = $connection->index('foo');
23 |
24 | self::assertSame('foo', $query->getIndex());
25 | }
26 |
27 | protected function getEnvironmentSetUp($app): void
28 | {
29 | $this->registerResolver($app);
30 | }
31 |
32 | protected function getPackageProviders($app): array
33 | {
34 | return [
35 | ElasticsearchServiceProvider::class,
36 | ];
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright 2017
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
13 | all 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
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/pagination/simple-bootstrap-4.php:
--------------------------------------------------------------------------------
1 | hasPages()): ?>
11 |
43 |
45 |
--------------------------------------------------------------------------------
/src/Exceptions/DocumentNotFoundException.php:
--------------------------------------------------------------------------------
1 | $model
32 | * @param string|array $ids
33 | *
34 | * @return $this
35 | * @psalm-suppress MoreSpecificImplementedParamType
36 | */
37 | public function setModel($model, $ids = []): self
38 | {
39 | $this->model = $model;
40 | $this->ids = Arr::wrap($ids);
41 |
42 | $this->message = "No query results for model [{$model}]";
43 |
44 | $this->message = count($this->ids) > 0
45 | ? $this->message . ' ' . implode(', ', $this->ids)
46 | : $this->message . '.';
47 |
48 | return $this;
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/Factories/ClientFactory.php:
--------------------------------------------------------------------------------
1 | logger !== null) {
38 | $config['logger'] = $this->logger;
39 | }
40 |
41 | unset($config['index']);
42 |
43 | return ClientBuilder::fromConfig($config);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/Pagination.php:
--------------------------------------------------------------------------------
1 | elements();
35 | $html = '';
36 |
37 | switch ($view) {
38 | case 'bootstrap-4':
39 | $html = require __DIR__ . '/pagination/bootstrap-4.php';
40 | break;
41 |
42 | case 'default':
43 | $html = require __DIR__ . '/pagination/default.php';
44 | break;
45 |
46 | case 'simple-bootstrap-4':
47 | $html = require __DIR__ . '/pagination/simple-bootstrap-4.php';
48 | break;
49 |
50 | case 'simple-default':
51 | $html = require __DIR__ . '/pagination/simple-default.php';
52 | break;
53 | }
54 |
55 | return new HtmlString($html);
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/Request.php:
--------------------------------------------------------------------------------
1 | getActual('created_at', 'asc'),
25 | $this->getExpected('created_at', 'asc'),
26 | );
27 |
28 | self::assertEquals(
29 | $this->getExpected('_score'),
30 | $this->getActual('_score'),
31 | );
32 | }
33 |
34 | /**
35 | * @param string $field
36 | * @param string $direction
37 | *
38 | * @return array
39 | */
40 | protected function getExpected(
41 | string $field,
42 | string $direction = 'desc',
43 | ): array {
44 | $query = $this->getQueryArray();
45 |
46 | $query["body"]["sort"][] = [$field => $direction];
47 |
48 | return $query;
49 | }
50 |
51 | /**
52 | * @param string $field
53 | * @param string $direction
54 | *
55 | * @return array
56 | */
57 | protected function getActual(
58 | string $field,
59 | string $direction = 'desc',
60 | ): array {
61 | return $this
62 | ->getQueryObject()
63 | ->orderBy($field, $direction)
64 | ->toArray();
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/Concerns/ExplainsQueries.php:
--------------------------------------------------------------------------------
1 | **Note:** If the Elasticsearch security features are enabled, you must
18 | * > have the read index privilege for the target index.
19 | *
20 | * @param string|null $id Document ID. Defaults to the ID in the query.
21 | * @param bool $lenient If `true`, format-based query failures (such as
22 | * providing text to a numeric field) will be ignored.
23 | * Defaults to `false`.
24 | *
25 | * @return array|null
26 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-explain.html
27 | */
28 | public function explain(string|null $id = null, bool $lenient = false): array|null
29 | {
30 | $body = $this->getBody();
31 | $query = $body['body'] ?? null;
32 | $source = $body['source'] ?? null;
33 | $id = $id ?? $this->getId();
34 |
35 | if (!$query || !$id) {
36 | return null;
37 | }
38 |
39 | return $this->getConnection()->getClient()->explain([
40 | 'index' => $this->getIndex(),
41 | 'lenient' => $lenient,
42 | 'id' => $id,
43 | 'body' => ['query' => $query],
44 | '_source' => $source ?? false,
45 | ]);
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/pagination/default.php:
--------------------------------------------------------------------------------
1 |
10 | hasPages()): ?>
12 |
65 |
67 |
--------------------------------------------------------------------------------
/src/Commands/DropIndexCommand.php:
--------------------------------------------------------------------------------
1 | option('connection') ?: null;
39 | $connection = $resolver->connection($connectionName);
40 | $force = $this->option('force') || 0;
41 | $client = $connection->getClient();
42 | $indices = !is_null($this->argument('index'))
43 | ? [$this->argument('index')]
44 | : array_keys(config('elasticsearch.indices', config('es.indices', [])));
45 |
46 | foreach ($indices as $index) {
47 | if (!$client->indices()->exists(['index' => $index])) {
48 | $this->warn("Index '{$index}' does not exist.");
49 |
50 | continue;
51 | }
52 |
53 | if (
54 | $force ||
55 | $this->confirm("Are you sure you want to drop the index '{$index}'?")
56 | ) {
57 | $this->info("Dropping index: {$index}");
58 | $client->indices()->delete(['index' => $index]);
59 | }
60 | }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/tests/ConnectionResolverTest.php:
--------------------------------------------------------------------------------
1 | createClient([]);
21 | $connection = new Connection($client);
22 |
23 | self::assertFalse($resolver->hasConnection('foo'));
24 |
25 | $resolver->addConnection('foo', $connection);
26 |
27 | self::assertTrue($resolver->hasConnection('foo'));
28 | }
29 |
30 | public function testConnection(): void
31 | {
32 | $resolver = new ConnectionResolver();
33 | $clientFactory = new ClientFactory();
34 | $client = $clientFactory->createClient([]);
35 | $connection = new Connection($client);
36 |
37 | self::assertFalse($resolver->hasConnection('foo'));
38 |
39 | $resolver->addConnection('foo', $connection);
40 |
41 | self::assertTrue($resolver->hasConnection('foo'));
42 | self::assertSame(
43 | $connection,
44 | $resolver->connection('foo'),
45 | );
46 | }
47 |
48 | public function testSetDefaultConnection(): void
49 | {
50 | $resolver = new ConnectionResolver();
51 | $clientFactory = new ClientFactory();
52 | $client = $clientFactory->createClient([]);
53 | $connection = new Connection($client);
54 |
55 | self::assertFalse($resolver->hasConnection('foo'));
56 |
57 | $resolver->addConnection('foo', $connection);
58 | $resolver->setDefaultConnection('foo');
59 |
60 | self::assertSame('foo', $resolver->getDefaultConnection());
61 | self::assertTrue($resolver->hasConnection('foo'));
62 | self::assertSame($connection, $resolver->connection());
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/Interfaces/ConnectionInterface.php:
--------------------------------------------------------------------------------
1 | env('SCOUT_DRIVER', 'elasticsearch'),
18 |
19 | /*
20 | |--------------------------------------------------------------------------
21 | | Index Prefix
22 | |--------------------------------------------------------------------------
23 | |
24 | | Here you may specify a prefix that will be applied to all search index
25 | | names used by Scout. This prefix may be useful if you have multiple
26 | | "tenants" or applications sharing the same search infrastructure.
27 | |
28 | */
29 | 'prefix' => env('SCOUT_PREFIX', ''),
30 |
31 | /*
32 | |--------------------------------------------------------------------------
33 | | Queue Data Syncing
34 | |--------------------------------------------------------------------------
35 | |
36 | | This option allows you to control if the operations that sync your data
37 | | with your search engines are queued. When this is set to "true" then
38 | | all automatic data syncing will get queued for better performance.
39 | |
40 | */
41 | 'queue' => false,
42 |
43 | /*
44 | |--------------------------------------------------------------------------
45 | | Algolia Configuration
46 | |--------------------------------------------------------------------------
47 | |
48 | | Here you may configure your Algolia settings. Algolia is a cloud hosted
49 | | search engine which works great with Scout out of the box. Just plug
50 | | in your application ID and admin API key to get started searching.
51 | |
52 | */
53 | 'algolia' => [
54 | 'id' => env('ALGOLIA_APP_ID', ''),
55 | 'secret' => env('ALGOLIA_SECRET', ''),
56 | ],
57 |
58 | 'elasticsearch' => [
59 | 'connection' => env('ELASTIC_CONNECTION', 'default'),
60 | ],
61 | ];
62 |
--------------------------------------------------------------------------------
/tests/BodyTest.php:
--------------------------------------------------------------------------------
1 | [
33 | "bool" => [
34 | "must" => [
35 | ["match" => ["address" => "mill"]],
36 | ],
37 | ],
38 | ],
39 | ];
40 |
41 | self::assertEquals(
42 | $this->getExpected($body),
43 | $this->getActual($body),
44 | );
45 | }
46 |
47 | /**
48 | * @param $body array
49 | *
50 | * @return array
51 | */
52 | protected function getExpected(array $body = []): array
53 | {
54 | return $this->getQueryArray($body);
55 | }
56 |
57 | /**
58 | * @param $body array
59 | *
60 | * @return array
61 | * @throws \PHPUnit\Framework\InvalidArgumentException
62 | * @throws ClassAlreadyExistsException
63 | * @throws ClassIsFinalException
64 | * @throws DuplicateMethodException
65 | * @throws InvalidMethodNameException
66 | * @throws OriginalConstructorInvocationRequiredException
67 | * @throws ReflectionException
68 | * @throws RuntimeException
69 | * @throws UnknownTypeException
70 | */
71 | protected function getActual(array $body = []): array
72 | {
73 | return $this
74 | ->getQueryObject()
75 | ->body($body)
76 | ->toArray();
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/pagination/bootstrap-4.php:
--------------------------------------------------------------------------------
1 |
13 | hasPages()): ?>
15 |
79 |
81 |
--------------------------------------------------------------------------------
/src/Concerns/ManagesIndices.php:
--------------------------------------------------------------------------------
1 | setConnection($this->getConnection());
25 |
26 | return $index->create();
27 | }
28 |
29 | /**
30 | * Create the configured index
31 | *
32 | * @param callable|null $callback
33 | *
34 | * @return array
35 | * @throws RuntimeException
36 | * @see Query::createIndex()
37 | */
38 | public function create(callable|null $callback = null): array
39 | {
40 | $index = $this->getIndex();
41 |
42 | if (!$index) {
43 | throw new RuntimeException('No index configured');
44 | }
45 |
46 | return $this->createIndex($index, $callback);
47 | }
48 |
49 | /**
50 | * Check existence of index
51 | *
52 | * @return bool
53 | * @throws RuntimeException
54 | */
55 | public function exists(): bool
56 | {
57 | $index = $this->getIndex();
58 |
59 | if (!$index) {
60 | throw new RuntimeException('No index configured');
61 | }
62 |
63 | $index = new Index($index);
64 |
65 | $index->setConnection($this->getConnection());
66 |
67 | return $index->exists();
68 | }
69 |
70 | /**
71 | * Drop index
72 | *
73 | * @param string $name
74 | *
75 | * @return array
76 | */
77 | public function dropIndex(string $name): array
78 | {
79 | $index = new Index($name);
80 | $index->connection = $this->getConnection();
81 |
82 | return $index->drop();
83 | }
84 |
85 | /**
86 | * Drop the configured index
87 | *
88 | * @return array
89 | * @throws RuntimeException
90 | */
91 | public function drop(): array
92 | {
93 | $index = $this->getIndex();
94 |
95 | if (!$index) {
96 | throw new RuntimeException('No index name configured');
97 | }
98 |
99 | return $this->dropIndex($index);
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/tests/IndexTest.php:
--------------------------------------------------------------------------------
1 | getExpected('index_1'), $this->getActual('index_1'));
44 | }
45 |
46 |
47 | protected function getExpected(string $index): array
48 | {
49 | $query = $this->getQueryArray();
50 |
51 | $query['index'] = $index;
52 |
53 | return $query;
54 | }
55 |
56 | /**
57 | * @throws InvalidArgumentException
58 | * @throws ClassAlreadyExistsException
59 | * @throws ClassIsFinalException
60 | * @throws ClassIsReadonlyException
61 | * @throws DuplicateMethodException
62 | * @throws InvalidMethodNameException
63 | * @throws OriginalConstructorInvocationRequiredException
64 | * @throws ReflectionException
65 | * @throws RuntimeException
66 | * @throws UnknownTypeException
67 | */
68 | protected function getActual(string $index): array
69 | {
70 | return $this->getQueryObject()->index($index)->toArray();
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/tests/SkipTest.php:
--------------------------------------------------------------------------------
1 | getExpected(10),
45 | $this->getActual(10),
46 | );
47 | }
48 |
49 | protected function getExpected(int $from): array
50 | {
51 | $query = $this->getQueryArray();
52 | $query['from'] = $from;
53 |
54 | return $query;
55 | }
56 |
57 | /**
58 | * @throws \PHPUnit\Framework\InvalidArgumentException
59 | * @throws ClassAlreadyExistsException
60 | * @throws ClassIsFinalException
61 | * @throws ClassIsReadonlyException
62 | * @throws DuplicateMethodException
63 | * @throws InvalidMethodNameException
64 | * @throws OriginalConstructorInvocationRequiredException
65 | * @throws ReflectionException
66 | * @throws RuntimeException
67 | * @throws UnknownTypeException
68 | */
69 | protected function getActual(int $from): array
70 | {
71 | return $this->getQueryObject()->skip($from)->toArray();
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/psalm.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/tests/SizeTest.php:
--------------------------------------------------------------------------------
1 | getExpected(15), $this->getActual(15));
44 | }
45 |
46 |
47 | /**
48 | * Get The expected results.
49 | */
50 | protected function getExpected(int $size): array
51 | {
52 | $query = $this->getQueryArray();
53 | $query['size'] = $size;
54 |
55 | return $query;
56 | }
57 |
58 |
59 | /**
60 | * Get The actual results.
61 | *
62 | * @throws \PHPUnit\Framework\InvalidArgumentException
63 | * @throws ClassAlreadyExistsException
64 | * @throws ClassIsFinalException
65 | * @throws ClassIsReadonlyException
66 | * @throws DuplicateMethodException
67 | * @throws InvalidMethodNameException
68 | * @throws OriginalConstructorInvocationRequiredException
69 | * @throws ReflectionException
70 | * @throws RuntimeException
71 | * @throws UnknownTypeException
72 | */
73 | protected function getActual(int $size): array
74 | {
75 | return $this->getQueryObject()->take($size)->toArray();
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/tests/IgnoreTest.php:
--------------------------------------------------------------------------------
1 | getExpected(404),
44 | $this->getActual(404),
45 | );
46 | self::assertEquals(
47 | $this->getExpected(500, 404),
48 | $this->getActual(500, 404),
49 | );
50 | }
51 |
52 | /**
53 | * @throws ClassAlreadyExistsException
54 | * @throws ClassIsFinalException
55 | * @throws DuplicateMethodException
56 | * @throws InvalidMethodNameException
57 | * @throws OriginalConstructorInvocationRequiredException
58 | * @throws ReflectionException
59 | * @throws RuntimeException
60 | * @throws UnknownTypeException
61 | * @throws \PHPUnit\Framework\InvalidArgumentException
62 | * @throws ClassIsReadonlyException
63 | */
64 | protected function getActual(int ...$args): array
65 | {
66 | return $this->getQueryObject()
67 | ->ignore($args)
68 | ->toArray();
69 | }
70 |
71 | protected function getExpected(int ...$args): array
72 | {
73 | $query = $this->getQueryArray();
74 | $query['client']['ignore'] = $args;
75 |
76 | return $query;
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "matchory/elasticsearch",
3 | "description": "The missing elasticsearch ORM for Laravel!",
4 | "keywords": [
5 | "php",
6 | "caching",
7 | "search-engine",
8 | "elasticsearch",
9 | "laravel",
10 | "eloquent",
11 | "orm",
12 | "model",
13 | "indexing",
14 | "query-builder",
15 | "scout"
16 | ],
17 | "license": "MIT",
18 | "type": "package",
19 | "homepage": "https://www.matchory.com",
20 | "support": {
21 | "issues": "https://github.com/matchory/elasticsearch/issues"
22 | },
23 | "authors": [
24 | {
25 | "name": "Moritz Friedrich",
26 | "homepage": "https://www.moritzfriedrich.com",
27 | "email": "moritz@matchory.com"
28 | },
29 | {
30 | "name": "Basem Khirat",
31 | "homepage": "http://basemkhirat.com",
32 | "email": "basemkhirat@gmail.com"
33 | }
34 | ],
35 | "autoload": {
36 | "psr-4": {
37 | "Matchory\\Elasticsearch\\": "src/"
38 | },
39 | "files": [
40 | "src/helpers.php"
41 | ]
42 | },
43 | "autoload-dev": {
44 | "psr-4": {
45 | "Matchory\\Elasticsearch\\Tests\\": "tests/"
46 | }
47 | },
48 | "require": {
49 | "php": "^8.3",
50 | "ext-json": "*",
51 | "elasticsearch/elasticsearch": "^7.17.3",
52 | "illuminate/pagination": "^12.25.0",
53 | "illuminate/support": "^12.25.0",
54 | "monolog/monolog": "*",
55 | "symfony/var-dumper": "*"
56 | },
57 | "require-dev": {
58 | "illuminate/contracts": "^12.25.0",
59 | "illuminate/database": "^12.25.0",
60 | "jetbrains/phpstorm-attributes": "^1.2.0",
61 | "laravel/framework": "^12.25.0",
62 | "laravel/pint": "^1.24.0",
63 | "laravel/scout": "^10.18.0",
64 | "matchory/laravel-server-timing": "^1.3.0",
65 | "orchestra/testbench": "*",
66 | "phpstan/phpstan": "^2.1.22",
67 | "phpunit/phpunit": "^11.5.34",
68 | "sentry/sentry-laravel": "^3.8.2|^4.15.1"
69 | },
70 | "prefer-stable": true,
71 | "extra": {
72 | "laravel": {
73 | "providers": [
74 | "Matchory\\Elasticsearch\\ElasticsearchServiceProvider"
75 | ],
76 | "aliases": {
77 | "ES": "Matchory\\Elasticsearch\\Facades\\ES"
78 | }
79 | }
80 | },
81 | "config": {
82 | "sort-packages": true,
83 | "allow-plugins": {
84 | "composer/package-versions-deprecated": true,
85 | "php-http/discovery": true
86 | }
87 | },
88 | "replace": {
89 | "basemkhirat/elasticsearch": "*"
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/tests/SelectTest.php:
--------------------------------------------------------------------------------
1 | getExpected('foo', 'bar'),
44 | $this->getActual('foo', 'bar'),
45 | );
46 | }
47 |
48 | /**
49 | * @throws ClassAlreadyExistsException
50 | * @throws ClassIsFinalException
51 | * @throws DuplicateMethodException
52 | * @throws InvalidMethodNameException
53 | * @throws OriginalConstructorInvocationRequiredException
54 | * @throws ReflectionException
55 | * @throws RuntimeException
56 | * @throws UnknownTypeException
57 | * @throws \PHPUnit\Framework\InvalidArgumentException
58 | * @throws ClassIsReadonlyException
59 | */
60 | protected function getActual(string ...$fields): array
61 | {
62 | return $this
63 | ->getQueryObject()
64 | ->select($fields)
65 | ->toArray();
66 | }
67 |
68 | /**
69 | * @param string ...$fields
70 | *
71 | * @return array
72 | */
73 | protected function getExpected(string ...$fields): array
74 | {
75 | $query = $this->getQueryArray();
76 |
77 | $query['body']['_source']['includes'] = $fields;
78 | $query['body']['_source']['excludes'] = [];
79 |
80 | return $query;
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/ConnectionResolver.php:
--------------------------------------------------------------------------------
1 |
24 | */
25 | protected array $connections = [];
26 |
27 | /**
28 | * The default connection name.
29 | *
30 | * @var string|null
31 | */
32 | protected string|null $default = null;
33 |
34 | /**
35 | * Create a new connection resolver instance.
36 | *
37 | * @param array $connections
38 | */
39 | public function __construct(array $connections = [])
40 | {
41 | foreach ($connections as $name => $connection) {
42 | $this->addConnection($name, $connection);
43 | }
44 | }
45 |
46 | /**
47 | * Add a connection to the resolver.
48 | *
49 | * @param string $name
50 | * @param ConnectionInterface $connection
51 | *
52 | * @return void
53 | */
54 | public function addConnection(
55 | string $name,
56 | ConnectionInterface $connection,
57 | ): void {
58 | $this->connections[$name] = $connection;
59 | }
60 |
61 | /**
62 | * Get a connection instance by name.
63 | *
64 | * @param string|null $name
65 | *
66 | * @return ConnectionInterface
67 | */
68 | public function connection(string|null $name = null): ConnectionInterface
69 | {
70 | if (is_null($name)) {
71 | $name = $this->getDefaultConnection();
72 | }
73 |
74 | return $this->connections[$name];
75 | }
76 |
77 | /**
78 | * Get the default connection name.
79 | *
80 | * @return string
81 | */
82 | public function getDefaultConnection(): string
83 | {
84 | return $this->default ?? '';
85 | }
86 |
87 | /**
88 | * Set the default connection name.
89 | *
90 | * @param string $name
91 | *
92 | * @return void
93 | */
94 | public function setDefaultConnection(string $name): void
95 | {
96 | $this->default = $name;
97 | }
98 |
99 | /**
100 | * Check if a connection has been registered.
101 | *
102 | * @param string $name
103 | *
104 | * @return bool
105 | */
106 | public function hasConnection(string $name): bool
107 | {
108 | return isset($this->connections[$name]);
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/tests/WhereInTest.php:
--------------------------------------------------------------------------------
1 | getExpected('status', ['pending', 'draft']),
44 | $this->getActual('status', ['pending', 'draft']),
45 | );
46 | }
47 |
48 | /**
49 | * @throws ClassAlreadyExistsException
50 | * @throws ClassIsFinalException
51 | * @throws DuplicateMethodException
52 | * @throws InvalidMethodNameException
53 | * @throws OriginalConstructorInvocationRequiredException
54 | * @throws ReflectionException
55 | * @throws RuntimeException
56 | * @throws UnknownTypeException
57 | * @throws \PHPUnit\Framework\InvalidArgumentException
58 | * @throws ClassIsReadonlyException
59 | */
60 | protected function getActual(string $name, array $value = []): array
61 | {
62 | return $this
63 | ->getQueryObject()
64 | ->whereIn($name, $value)
65 | ->toArray();
66 | }
67 |
68 | /**
69 | * @param string $name
70 | * @param array $value
71 | *
72 | * @return array
73 | */
74 | protected function getExpected(string $name, array $value = []): array
75 | {
76 | $query = $this->getQueryArray();
77 |
78 | $query['body']['query']['bool']['filter'][] = [
79 | 'terms' => [
80 | $name => $value,
81 | ],
82 | ];
83 |
84 | return $query;
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/tests/WhereNotInTest.php:
--------------------------------------------------------------------------------
1 | getExpected('status', ['pending', 'draft']),
45 | $this->getActual('status', ['pending', 'draft']),
46 | );
47 | }
48 |
49 | /**
50 | * @param string $name
51 | * @param array $value
52 | *
53 | * @return array
54 | */
55 | protected function getExpected(string $name, array $value = []): array
56 | {
57 | $query = $this->getQueryArray();
58 |
59 | $query['body']['query']['bool']['must_not'][] = [
60 | 'terms' => [$name => $value],
61 | ];
62 |
63 | return $query;
64 | }
65 |
66 | /**
67 | * @throws \PHPUnit\Framework\InvalidArgumentException
68 | * @throws ClassAlreadyExistsException
69 | * @throws ClassIsFinalException
70 | * @throws ClassIsReadonlyException
71 | * @throws DuplicateMethodException
72 | * @throws InvalidMethodNameException
73 | * @throws OriginalConstructorInvocationRequiredException
74 | * @throws ReflectionException
75 | * @throws RuntimeException
76 | * @throws UnknownTypeException
77 | */
78 | protected function getActual(string $name, array $value = []): array
79 | {
80 | return $this
81 | ->getQueryObject()
82 | ->whereNotIn($name, $value)
83 | ->toArray();
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/tests/SearchTest.php:
--------------------------------------------------------------------------------
1 | getExpected('foo'),
46 | $this->getActual('foo'),
47 | );
48 | }
49 |
50 | /**
51 | * Get The actual results.
52 | *
53 | * @throws \PHPUnit\Framework\InvalidArgumentException
54 | * @throws ClassAlreadyExistsException
55 | * @throws ClassIsFinalException
56 | * @throws ClassIsReadonlyException
57 | * @throws DuplicateMethodException
58 | * @throws InvalidMethodNameException
59 | * @throws OriginalConstructorInvocationRequiredException
60 | * @throws ReflectionException
61 | * @throws RuntimeException
62 | * @throws UnknownTypeException
63 | */
64 | protected function getActual(string $q, int $boost = 1): array
65 | {
66 | return $this->getQueryObject()
67 | ->search($q, boost: $boost)
68 | ->toArray();
69 | }
70 |
71 | /**
72 | * Get The expected results.
73 | */
74 | protected function getExpected(array|string $body, int|float $boost = 1): array
75 | {
76 | $query = $this->getQueryArray();
77 | $search_params = [];
78 | $search_params['query'] = $body;
79 |
80 | if ($boost > 1) {
81 | $search_params['boost'] = $boost;
82 | }
83 |
84 | $query['body']['query']['bool']['must'][] = [
85 | 'query_string' => $search_params,
86 | ];
87 |
88 | return $query;
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/Classes/Search.php:
--------------------------------------------------------------------------------
1 | query = $query;
68 | $this->queryString = $queryString;
69 |
70 | if (is_callable($settings)) {
71 | $settings($this);
72 | }
73 |
74 | // TODO: What is the purpose of this property?
75 | $this->settings = $settings;
76 | }
77 |
78 | /**
79 | * Set search boost factor
80 | *
81 | * @param int $boost
82 | *
83 | * @return $this
84 | */
85 | public function boost(int $boost = 1): self
86 | {
87 | $this->boost = $boost;
88 |
89 | return $this;
90 | }
91 |
92 | /**
93 | * Build the native query
94 | */
95 | public function build(): void
96 | {
97 | $queryParams = [
98 | self::PARAMETER_QUERY => $this->queryString,
99 | ];
100 |
101 | if ($this->boost > 1) {
102 | $queryParams[self::PARAMETER_BOOST] = $this->boost;
103 | }
104 |
105 | if (count($this->fields)) {
106 | $queryParams[self::PARAMETER_FIELDS] = $this->fields;
107 | }
108 |
109 | $this->query->must[] = [
110 | 'query_string' => $queryParams,
111 | ];
112 | }
113 |
114 | /**
115 | * Set searchable fields
116 | *
117 | * @param array $fields
118 | *
119 | * @return $this
120 | */
121 | public function fields(array $fields = []): self
122 | {
123 | $searchable = [];
124 |
125 | foreach ($fields as $field => $weight) {
126 | $weightSuffix = $weight > 1 ? "^{$weight}" : '';
127 | $searchable[] = $field . $weightSuffix;
128 | }
129 |
130 | $this->fields = $searchable;
131 |
132 | return $this;
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/src/Concerns/HasGlobalScopes.php:
--------------------------------------------------------------------------------
1 | >
30 | */
31 | protected static $globalScopes = [];
32 |
33 | /**
34 | * Register a new global scope on the model.
35 | *
36 | * @param string|Closure|ScopeInterface $scope
37 | * @param Closure|null $implementation
38 | *
39 | * @return Closure|ScopeInterface
40 | *
41 | * @throws InvalidArgumentException
42 | */
43 | public static function addGlobalScope(
44 | ScopeInterface|string|Closure $scope,
45 | Closure|null $implementation = null,
46 | ): ScopeInterface|Closure {
47 | if (is_string($scope) && !is_null($implementation)) {
48 | return static::$globalScopes[static::class][$scope] = $implementation;
49 | }
50 |
51 | if ($scope instanceof Closure) {
52 | return static::$globalScopes[static::class][spl_object_hash($scope)] = $scope;
53 | }
54 |
55 | if ($scope instanceof ScopeInterface) {
56 | return static::$globalScopes[static::class][get_class($scope)] = $scope;
57 | }
58 |
59 | throw new InvalidArgumentException(
60 | 'Global scopes must be callable or implement ScopeInterface',
61 | );
62 | }
63 |
64 | /**
65 | * Determine if a model has a global scope.
66 | *
67 | * @param ScopeInterface|string $scope
68 | *
69 | * @return bool
70 | */
71 | public static function hasGlobalScope($scope): bool
72 | {
73 | return (bool) static::getGlobalScope($scope);
74 | }
75 |
76 | /**
77 | * Get a global scope registered with the model.
78 | *
79 | * @param ScopeInterface|string $scope
80 | *
81 | * @return ScopeInterface|Closure|null
82 | */
83 | public static function getGlobalScope($scope)
84 | {
85 | if (is_string($scope)) {
86 | return static::$globalScopes[static::class][$scope] ?? null;
87 | }
88 |
89 | return static::$globalScopes[static::class][get_class($scope)] ?? null;
90 | }
91 |
92 | /**
93 | * Get the global scopes for this class instance.
94 | *
95 | * @return array
96 | */
97 | public function getGlobalScopes(): array
98 | {
99 | return static::$globalScopes[static::class] ?? [];
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/tests/DistanceTest.php:
--------------------------------------------------------------------------------
1 | getExpected('location', ['lat' => -33.8688197, 'lon' => 151.20929550000005], '10km'),
45 | $this->getActual('location', ['lat' => -33.8688197, 'lon' => 151.20929550000005], '10km'),
46 | );
47 |
48 | self::assertNotEquals(
49 | $this->getExpected('location', ['lat' => -33.8688197, 'lon' => 151.20929550000005], '10km'),
50 | $this->getActual('location', ['lat' => -33.8688197, 'lon' => 151.20929550000005], '15km'),
51 | );
52 | }
53 |
54 |
55 | protected function getExpected(string $field, array $value, string $distance): array
56 | {
57 | $query = $this->getQueryArray();
58 |
59 | $query['body']['query']['bool']['filter'][] = [
60 | 'geo_distance' => [
61 | $field => $value,
62 | 'distance' => $distance,
63 | ],
64 | ];
65 |
66 | return $query;
67 | }
68 |
69 | /**
70 | * @throws \PHPUnit\Framework\InvalidArgumentException
71 | * @throws ClassAlreadyExistsException
72 | * @throws ClassIsFinalException
73 | * @throws ClassIsReadonlyException
74 | * @throws DuplicateMethodException
75 | * @throws InvalidMethodNameException
76 | * @throws OriginalConstructorInvocationRequiredException
77 | * @throws ReflectionException
78 | * @throws RuntimeException
79 | * @throws UnknownTypeException
80 | */
81 | protected function getActual($field, $geo_point, $distance): array
82 | {
83 | return $this->getQueryObject()->distance($field, $geo_point, $distance)->toArray();
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/tests/WhereBetweenTest.php:
--------------------------------------------------------------------------------
1 | getExpected('views', 500, 1000),
44 | $this->getActual('views', 500, 1000),
45 | );
46 |
47 | self::assertEquals(
48 | $this->getExpected('views', [500, 1000]),
49 | $this->getActual('views', [500, 1000]),
50 | );
51 | }
52 |
53 | /**
54 | * @param int|array{int, int} $first
55 | *
56 | * @throws ClassAlreadyExistsException
57 | * @throws ClassIsFinalException
58 | * @throws DuplicateMethodException
59 | * @throws InvalidArgumentException
60 | * @throws InvalidMethodNameException
61 | * @throws OriginalConstructorInvocationRequiredException
62 | * @throws ReflectionException
63 | * @throws RuntimeException
64 | * @throws UnknownTypeException
65 | * @throws ClassIsReadonlyException
66 | */
67 | protected function getActual(string $name, int|array $first, int|null $last = null): array
68 | {
69 | return $this
70 | ->getQueryObject()
71 | ->whereBetween($name, $first, $last)
72 | ->toArray();
73 | }
74 |
75 | protected function getExpected(string $name, int|array $first, int|null $last = null): array
76 | {
77 | $query = $this->getQueryArray();
78 |
79 | if (is_array($first) && count($first) === 2) {
80 | [$first, $last] = $first;
81 | }
82 |
83 | $query['body']['query']['bool']['filter'][] = [
84 | 'range' => [
85 | $name => [
86 | 'gte' => $first,
87 | 'lte' => $last,
88 | ],
89 | ],
90 | ];
91 |
92 | return $query;
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/tests/WhereNotBetweenTest.php:
--------------------------------------------------------------------------------
1 | getExpected('views', 500, 1000),
46 | $this->getActual('views', 500, 1000),
47 | );
48 |
49 | self::assertEquals(
50 | $this->getExpected('views', [500, 1000]),
51 | $this->getActual('views', [500, 1000]),
52 | );
53 |
54 | }
55 |
56 |
57 | /**
58 | * Get The expected results.
59 | */
60 | protected function getExpected($name, $first_value, $second_value = null): array
61 | {
62 | $query = $this->getQueryArray();
63 |
64 | if (is_array($first_value) && count($first_value) == 2) {
65 | $second_value = $first_value[1];
66 | $first_value = $first_value[0];
67 | }
68 |
69 | $query['body']['query']['bool']['must_not'][] = ['range' => [$name => ['gte' => $first_value, 'lte' => $second_value]]];
70 |
71 | return $query;
72 | }
73 |
74 |
75 | /**
76 | * Get The actual results.
77 | *
78 | * @throws \PHPUnit\Framework\InvalidArgumentException
79 | * @throws ClassAlreadyExistsException
80 | * @throws ClassIsFinalException
81 | * @throws ClassIsReadonlyException
82 | * @throws DuplicateMethodException
83 | * @throws InvalidMethodNameException
84 | * @throws OriginalConstructorInvocationRequiredException
85 | * @throws ReflectionException
86 | * @throws RuntimeException
87 | * @throws UnknownTypeException
88 | */
89 | protected function getActual($name, $first_value, $second_value = null): array
90 | {
91 | return $this->getQueryObject()->whereNotBetween($name, $first_value, $second_value)->toArray();
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/src/Commands/ListIndicesCommand.php:
--------------------------------------------------------------------------------
1 | option('connection') ?: null;
62 | $connection = $resolver->connection($connectionName)->newQuery();
63 | $indices = $connection->raw()->cat()->indices();
64 | $indices = is_array($indices)
65 | ? $this->getIndicesFromArrayResponse($indices)
66 | : $this->getIndicesFromStringResponse($indices);
67 |
68 | if (count($indices)) {
69 | $this->table($this->headers, $indices);
70 | } else {
71 | $this->warn('No indices found.');
72 | }
73 | }
74 |
75 | /**
76 | * Get a list of indices data
77 | * Match newer versions of elasticsearch/elasticsearch package (5.1.1 or higher)
78 | */
79 | public function getIndicesFromArrayResponse(array $indices): array
80 | {
81 | $data = [];
82 |
83 | foreach ($indices as $row) {
84 | $row = array_key_exists($row['index'], config('elasticsearch.indices', config('es.indices', [])))
85 | ? Arr::prepend($row, 'yes')
86 | : Arr::prepend($row, 'no');
87 |
88 | $data[] = $row;
89 | }
90 |
91 | return $data;
92 | }
93 |
94 | /**
95 | * Get list of indices data
96 | * Match older versions of elasticsearch/elasticsearch package.
97 | */
98 | public function getIndicesFromStringResponse(string $indices): array
99 | {
100 | $lines = explode(PHP_EOL, trim($indices));
101 | $data = [];
102 |
103 | foreach ($lines as $line) {
104 | $line_array = explode(' ', trim($line));
105 | $row = [];
106 |
107 | foreach ($line_array as $item) {
108 | if (trim($item) !== '') {
109 | $row[] = $item;
110 | }
111 | }
112 |
113 | $row = array_key_exists($row[2], config('elasticsearch.indices', config('es.indices', [])))
114 | ? Arr::prepend($row, 'yes')
115 | : Arr::prepend($row, 'no');
116 |
117 | $data[] = $row;
118 | }
119 |
120 | return $data;
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/src/Commands/CreateIndexCommand.php:
--------------------------------------------------------------------------------
1 | option('connection') ?: null;
36 | $connection = $resolver->connection($connectionName)->newQuery();
37 | $client = $connection->raw();
38 |
39 | /** @var string[] $indices */
40 | $indices = !is_null($this->argument('index'))
41 | ? [$this->argument('index')]
42 | : array_keys(config('elasticsearch.indices', config('elasticsearch.indices', config('es.indices', []))));
43 |
44 | foreach ($indices as $index) {
45 | $config = config("elasticsearch.indices.{$index}", config("es.indices.{$index}"));
46 |
47 | if (is_null($config)) {
48 | $this->warn("Missing configuration for index: {$index}");
49 |
50 | continue;
51 | }
52 |
53 | if ($client->indices()->exists(['index' => $index])) {
54 | $this->warn("Index {$index} already exists!");
55 |
56 | continue;
57 | }
58 |
59 | // Create index with settings from config file
60 |
61 | $this->info("Creating index: {$index}");
62 |
63 | $client->indices()->create([
64 | 'index' => $index,
65 | 'body' => [
66 | 'settings' => $config['settings'],
67 | ],
68 | ]);
69 |
70 | if (isset($config['aliases'])) {
71 | foreach ($config['aliases'] as $alias) {
72 | $this->info("Creating alias: {$alias} for index: {$index}");
73 |
74 | $client->indices()->updateAliases([
75 | 'body' => [
76 | 'actions' => [
77 | [
78 | 'add' => [
79 | 'index' => $index,
80 | 'alias' => $alias,
81 | ],
82 | ],
83 | ],
84 |
85 | ],
86 | ]);
87 | }
88 | }
89 |
90 | if (isset($config['mappings'])) {
91 | foreach ($config['mappings'] as $mapping) {
92 | $this->info(
93 | "Creating mapping for index: {$index}",
94 | );
95 |
96 | // Create mapping for type from config file
97 | $client->indices()->putMapping([
98 | 'index' => $index,
99 | 'body' => $mapping,
100 | 'include_type_name' => true,
101 | ]);
102 | }
103 | }
104 | }
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/tests/Traits/ResolvesConnections.php:
--------------------------------------------------------------------------------
1 |
27 | */
28 | protected $elasticsearchClient;
29 |
30 | /**
31 | * @return MockObject
32 | * @throws ClassAlreadyExistsException
33 | * @throws ClassIsFinalException
34 | * @throws DuplicateMethodException
35 | * @throws InvalidArgumentException
36 | * @throws InvalidMethodNameException
37 | * @throws OriginalConstructorInvocationRequiredException
38 | * @throws ReflectionException
39 | * @throws RuntimeException
40 | * @throws UnknownTypeException
41 | */
42 | public function mockClient(): MockObject
43 | {
44 | if (! $this->elasticsearchClient) {
45 | $this->elasticsearchClient = $this
46 | ->getMockBuilder(Client::class)
47 | ->disableOriginalConstructor()
48 | ->getMock();
49 | }
50 |
51 | return $this->elasticsearchClient;
52 | }
53 |
54 | /**
55 | * @return ConnectionResolver
56 | * @throws InvalidArgumentException
57 | * @throws ClassAlreadyExistsException
58 | * @throws ClassIsFinalException
59 | * @throws DuplicateMethodException
60 | * @throws InvalidMethodNameException
61 | * @throws OriginalConstructorInvocationRequiredException
62 | * @throws ReflectionException
63 | * @throws RuntimeException
64 | * @throws UnknownTypeException
65 | */
66 | public function createConnectionResolver(): ConnectionResolver
67 | {
68 | /** @var Client $mock */
69 | $mock = $this->mockClient();
70 |
71 | $connection = new Connection($mock);
72 | $connectionName = $this->getDefaultConnectionName();
73 |
74 | $resolver = new ConnectionResolver([
75 | $connectionName => $connection,
76 | ]);
77 |
78 | $resolver->setDefaultConnection($connectionName);
79 |
80 | return $resolver;
81 | }
82 |
83 | protected function getDefaultConnectionName(): string
84 | {
85 | return 'default';
86 | }
87 |
88 | /**
89 | * @param Application $application
90 | *
91 | * @throws ClassAlreadyExistsException
92 | * @throws ClassIsFinalException
93 | * @throws DuplicateMethodException
94 | * @throws InvalidArgumentException
95 | * @throws InvalidMethodNameException
96 | * @throws OriginalConstructorInvocationRequiredException
97 | * @throws ReflectionException
98 | * @throws RuntimeException
99 | * @throws UnknownTypeException
100 | */
101 | protected function registerResolver(Application $application): void
102 | {
103 | $resolver = $this->createConnectionResolver();
104 |
105 | $application->instance(
106 | ConnectionResolverInterface::class,
107 | $resolver,
108 | );
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/tests/Traits/ESQueryTrait.php:
--------------------------------------------------------------------------------
1 | getMockBuilder(Client::class)
64 | ->disableOriginalConstructor()
65 | ->getMock();
66 | }
67 |
68 | /**
69 | * @return Connection
70 | * @throws ClassAlreadyExistsException
71 | * @throws ClassIsFinalException
72 | * @throws ClassIsReadonlyException
73 | * @throws DuplicateMethodException
74 | * @throws InvalidArgumentException
75 | * @throws InvalidMethodNameException
76 | * @throws OriginalConstructorInvocationRequiredException
77 | * @throws ReflectionException
78 | * @throws RuntimeException
79 | * @throws UnknownTypeException
80 | */
81 | protected function getConnection(): Connection
82 | {
83 | return new Connection($this->getClient());
84 | }
85 |
86 | /**
87 | * Expected query array
88 | *
89 | * @param array $body
90 | *
91 | * @return array
92 | */
93 | protected function getQueryArray(array $body = []): array
94 | {
95 | return [
96 | 'index' => $this->index,
97 | 'body' => $body,
98 | 'from' => $this->skip,
99 | 'size' => $this->take,
100 | ];
101 | }
102 |
103 | /**
104 | * ES query object
105 | *
106 | * @param Query|null $query
107 | *
108 | * @return Query
109 | * @throws ClassAlreadyExistsException
110 | * @throws ClassIsFinalException
111 | * @throws ClassIsReadonlyException
112 | * @throws DuplicateMethodException
113 | * @throws InvalidArgumentException
114 | * @throws InvalidMethodNameException
115 | * @throws OriginalConstructorInvocationRequiredException
116 | * @throws ReflectionException
117 | * @throws RuntimeException
118 | * @throws UnknownTypeException
119 | */
120 | protected function getQueryObject(?Query $query = null): Query
121 | {
122 | return ($query ?? new Query($this->getConnection()))
123 | ->index($this->index)
124 | ->take($this->take)
125 | ->skip($this->skip);
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/src/Commands/UpdateIndexCommand.php:
--------------------------------------------------------------------------------
1 | option('connection') ?: null;
37 | $connection = $resolver->connection($connectionName);
38 | $client = $connection->getClient();
39 | $indices = !is_null($this->argument('index'))
40 | ? [$this->argument('index')]
41 | : array_keys(config('elasticsearch.indices', config('es.indices', [])));
42 |
43 | foreach ($indices as $index) {
44 | $config = config("elasticsearch.indices.{$index}", config("es.indices.{$index}"));
45 |
46 | if (is_null($config)) {
47 | $this->warn("Missing configuration for index: {$index}");
48 | continue;
49 | }
50 |
51 | if (!$client->indices()->exists(['index' => $index])) {
52 | $this->call('es:indices:create', [
53 | 'index' => $index,
54 | ]);
55 |
56 | return;
57 | }
58 |
59 | $this->info("Removing aliases for index: {$index}");
60 |
61 | // The index is already exists. update aliases and setting
62 | // Remove all index aliases
63 | $client->indices()->updateAliases([
64 | 'body' => [
65 | 'actions' => [
66 | [
67 | 'remove' => [
68 | 'index' => $index,
69 | 'alias' => '*',
70 | ],
71 | ],
72 | ],
73 |
74 | ],
75 |
76 | 'client' => ['ignore' => [404]],
77 | ]);
78 |
79 | // Update index aliases from config
80 | if (isset($config['aliases'])) {
81 | foreach ($config['aliases'] as $alias) {
82 | $this->info(
83 | "Creating alias: {$alias} for index: {$index}",
84 | );
85 |
86 | $client->indices()->updateAliases([
87 | 'body' => [
88 | 'actions' => [
89 | [
90 | 'add' => [
91 | 'index' => $index,
92 | 'alias' => $alias,
93 | ],
94 | ],
95 | ],
96 |
97 | ],
98 | ]);
99 | }
100 | }
101 |
102 | // Create mapping for type from config file
103 | if (isset($config['mappings'])) {
104 | foreach ($config['mappings'] as $mapping) {
105 | $this->info(
106 | "Creating mapping for index: {$index}",
107 | );
108 |
109 | $client->indices()->putMapping([
110 | 'index' => $index,
111 | 'body' => $mapping,
112 | ]);
113 | }
114 | }
115 | }
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/Facades/Elasticsearch.php:
--------------------------------------------------------------------------------
1 |
22 | * @package Matchory\Elasticsearch
23 | */
24 | class Collection extends BaseCollection
25 | {
26 | /**
27 | * Collection constructor.
28 | *
29 | * @param iterable $items
30 | * @param int|null $total
31 | * @param float|null $maxScore
32 | * @param float|null $duration
33 | * @param bool|null $timedOut
34 | * @param string|null $scrollId
35 | * @param stdClass|null $shards
36 | * @param array|null $suggestions
37 | * @param array|null $aggregations
38 | */
39 | public function __construct(
40 | iterable $items = [],
41 | protected int|null $total = null,
42 | protected float|null $maxScore = null,
43 | protected float|null $duration = null,
44 | protected bool|null $timedOut = null,
45 | protected string|null $scrollId = null,
46 | protected stdClass|null $shards = null,
47 | protected array|null $suggestions = null,
48 | protected array|null $aggregations = null,
49 | ) {
50 | parent::__construct($items);
51 | }
52 |
53 | public static function fromResponse(
54 | array $response,
55 | array|null $items = null,
56 | ): self {
57 | $items = $items ?? $response['hits']['hits'] ?? [];
58 |
59 | $maxScore = (float) $response['hits']['max_score'];
60 | $duration = (float) $response['took'];
61 | $timedOut = (bool) $response['timed_out'];
62 | $scrollId = (string) ($response['_scroll_id'] ?? null);
63 | /** @var stdClass $shards */
64 | $shards = (object) $response['_shards'];
65 | $suggestions = $response['suggest'] ?? [];
66 | $aggregations = $response['aggregations'] ?? [];
67 | $total = (int) (
68 | is_array($response['hits']['total'])
69 | ? $response['hits']['total']['value']
70 | : $response['hits']['total']
71 | );
72 |
73 | return new self(
74 | $items,
75 | $total,
76 | $maxScore,
77 | $duration,
78 | $timedOut,
79 | $scrollId,
80 | $shards,
81 | $suggestions,
82 | $aggregations,
83 | );
84 | }
85 |
86 | public function getAggregations(): BaseCollection
87 | {
88 | return new BaseCollection($this->aggregations);
89 | }
90 |
91 | public function getAllSuggestions(): BaseCollection
92 | {
93 | return BaseCollection::make($this->suggestions)
94 | ->mapInto(BaseCollection::class);
95 | }
96 |
97 | public function getDuration(): float|null
98 | {
99 | return $this->duration;
100 | }
101 |
102 | public function getMaxScore(): float|null
103 | {
104 | return $this->maxScore;
105 | }
106 |
107 | public function getScrollId(): string|null
108 | {
109 | return $this->scrollId;
110 | }
111 |
112 | public function getShards(): stdClass|null
113 | {
114 | return $this->shards;
115 | }
116 |
117 | public function getSuggestions(string $name): BaseCollection
118 | {
119 | return new BaseCollection($this->suggestions[$name] ?? []);
120 | }
121 |
122 | public function getTotal(): int|null
123 | {
124 | return $this->total;
125 | }
126 |
127 | public function isTimedOut(): bool|null
128 | {
129 | return $this->timedOut;
130 | }
131 |
132 | /**
133 | * Get the collection of items as JSON.
134 | *
135 | * @param int $options
136 | *
137 | * @return string
138 | * @throws JsonException
139 | */
140 | public function toJson($options = 0): string
141 | {
142 | return json_encode(
143 | $this->toArray(),
144 | JSON_THROW_ON_ERROR | $options,
145 | );
146 | }
147 |
148 | /**
149 | * @inheritDoc
150 | * @psalm-suppress DocblockTypeContradiction
151 | */
152 | public function toArray(): array
153 | {
154 | return array_map(static function ($item) {
155 | return $item instanceof Arrayable
156 | ? $item->toArray()
157 | : $item;
158 | }, $this->items);
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/src/ConnectionManager.php:
--------------------------------------------------------------------------------
1 |
39 | */
40 | protected array $connections = [];
41 |
42 | /**
43 | * Create a new connection resolver instance.
44 | *
45 | * @param array $configuration
46 | * @param ClientFactoryInterface $clientFactory
47 | * @param CacheInterface|null $cache
48 | */
49 | public function __construct(
50 | protected array $configuration,
51 | protected readonly ClientFactoryInterface $clientFactory,
52 | protected readonly CacheInterface|null $cache = null,
53 | ) {}
54 |
55 | /**
56 | * Dynamically pass methods to the default connection.
57 | *
58 | * @param string $method
59 | * @param array $parameters
60 | *
61 | * @return mixed
62 | * @throws InvalidArgumentException
63 | */
64 | public function __call(string $method, array $parameters)
65 | {
66 | return $this->connection()->$method(...$parameters);
67 | }
68 |
69 | /**
70 | * Get a connection instance by name.
71 | *
72 | * @param string|null $name
73 | *
74 | * @return ConnectionInterface
75 | * @throws InvalidArgumentException
76 | */
77 | public function connection(string|null $name = null): ConnectionInterface
78 | {
79 | if (is_null($name)) {
80 | $name = $this->getDefaultConnection();
81 | }
82 |
83 | if (!isset($this->connections[$name])) {
84 | $this->connections[$name] = $this->makeConnection($name);
85 | }
86 |
87 | return $this->connections[$name];
88 | }
89 |
90 | /**
91 | * Get the default connection name.
92 | *
93 | * @return string
94 | */
95 | public function getDefaultConnection(): string
96 | {
97 | return $this->configuration[self::CONFIG_KEY_DEFAULT_CONNECTION] ?? '';
98 | }
99 |
100 | /**
101 | * @param string $name
102 | *
103 | * @return ConnectionInterface
104 | * @throws InvalidArgumentException
105 | */
106 | protected function makeConnection(string $name): ConnectionInterface
107 | {
108 | $config = $this->configuration[self::CONFIG_KEY_CONNECTIONS][$name] ?? null;
109 |
110 | if (!$config) {
111 | throw new InvalidArgumentException(
112 | "Elasticsearch connection [{$name}] not configured.",
113 | );
114 | }
115 |
116 | $client = $this->clientFactory->createClient($config);
117 |
118 | return new Connection(
119 | $client,
120 | $this->cache,
121 | $config[self::CONFIG_KEY_INDEX] ?? null,
122 | $config[self::CONFIG_KEY_REPORT_QUERIES] ?? true,
123 | );
124 | }
125 |
126 | /**
127 | * Add a connection to the resolver.
128 | *
129 | * @param string $name
130 | * @param ConnectionInterface $connection
131 | *
132 | * @return void
133 | */
134 | public function addConnection(
135 | string $name,
136 | ConnectionInterface $connection,
137 | ): void {
138 | $this->connections[$name] = $connection;
139 | }
140 |
141 | /**
142 | * Set the default connection name.
143 | *
144 | * @param string $name
145 | *
146 | * @return void
147 | */
148 | public function setDefaultConnection(string $name): void
149 | {
150 | $this->configuration[self::CONFIG_KEY_DEFAULT_CONNECTION] = $name;
151 | }
152 |
153 | /**
154 | * Check if a connection has been registered.
155 | *
156 | * @param string $name
157 | *
158 | * @return bool
159 | */
160 | public function hasConnection(string $name): bool
161 | {
162 | return isset($this->connections[$name]);
163 | }
164 | }
165 |
--------------------------------------------------------------------------------
/tests/ConnectionManagerTest.php:
--------------------------------------------------------------------------------
1 | getDefaultConnection());
41 |
42 | $instance->setDefaultConnection('foo');
43 |
44 | self::assertSame('foo', $instance->getDefaultConnection());
45 | }
46 |
47 | /**
48 | *
49 | * @noinspection PhpParamsInspection
50 | */
51 | public function testResolvesDefaultConnectionIfNameNotSpecified(): void
52 | {
53 | $instance = new ConnectionManager(
54 | [],
55 | new ClientFactory(),
56 | );
57 | $connection = $this->mock(ConnectionInterface::class);
58 | $instance->addConnection('', $connection);
59 |
60 | self::assertSame($connection, $instance->connection());
61 | }
62 |
63 | /**
64 | *
65 | * @noinspection PhpParamsInspection
66 | */
67 | public function testResolvesConnectionsByName(): void
68 | {
69 | $manager = new ConnectionManager(
70 | [],
71 | new ClientFactory(),
72 | );
73 | $c1 = $this->mock(ConnectionInterface::class);
74 | $c2 = $this->mock(ConnectionInterface::class);
75 |
76 | $manager->addConnection('foo', $c1);
77 | $manager->addConnection('bar', $c2);
78 |
79 | self::assertSame($c1, $manager->connection('foo'));
80 | self::assertSame($c2, $manager->connection('bar'));
81 | }
82 |
83 | public function testCreatesConnectionsWithCacheInstance(): void
84 | {
85 | /** @var CacheInterface&Mock $cache */
86 | $cache = $this->mock(CacheInterface::class);
87 | $instance = new ConnectionManager(
88 | [
89 | 'connections' => [
90 | 'foo' => [
91 | 'servers' => [
92 | '0.0.0.0',
93 | ],
94 | ],
95 | ],
96 | ],
97 | new ClientFactory(),
98 | $cache,
99 | );
100 |
101 | $connection = $instance->connection('foo');
102 | self::assertSame($cache, $connection->getCache());
103 | }
104 |
105 | /**
106 | * @throws ExpectationFailedException
107 | * @throws InvalidArgumentException
108 | * @noinspection PhpParamsInspection
109 | */
110 | public function testAddsConnection(): void
111 | {
112 | $instance = new ConnectionManager(
113 | [],
114 | new ClientFactory(),
115 | );
116 |
117 | self::assertFalse($instance->hasConnection('foo'));
118 |
119 | $instance->addConnection('foo', $this->mock(
120 | ConnectionInterface::class,
121 | ));
122 |
123 | self::assertTrue($instance->hasConnection('foo'));
124 | }
125 |
126 | /** @noinspection PhpUndefinedMethodInspection */
127 | public function testProxiesCallsToDefaultConnection(): void
128 | {
129 | $manager = new ConnectionManager(
130 | [],
131 | new ClientFactory(),
132 | );
133 |
134 | $expected = 42;
135 | $connection = $this
136 | ->getMockBuilder(Connection::class)
137 | ->disableOriginalConstructor()
138 | ->getMock();
139 |
140 | $connection
141 | ->expects(self::any())
142 | ->method('__call')
143 | ->with('test')
144 | ->willReturnCallback(function ($method, $args) use ($expected) {
145 | self::assertSame($expected, $args[0]);
146 | });
147 |
148 | $manager->addConnection('', $connection);
149 | // @phpstan-ignore-next-line
150 | $manager->test($expected);
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/tests/WhereNotTest.php:
--------------------------------------------------------------------------------
1 | ',
29 | '>=',
30 | '<',
31 | '<=',
32 | 'like',
33 | 'exists',
34 | ];
35 |
36 | /**
37 | * Test the whereNot() method.
38 | *
39 | * @throws ClassAlreadyExistsException
40 | * @throws ClassIsFinalException
41 | * @throws ClassIsReadonlyException
42 | * @throws DuplicateMethodException
43 | * @throws ExpectationFailedException
44 | * @throws InvalidArgumentException
45 | * @throws InvalidMethodNameException
46 | * @throws OriginalConstructorInvocationRequiredException
47 | * @throws ReflectionException
48 | * @throws RuntimeException
49 | * @throws UnknownTypeException
50 | * @throws \PHPUnit\Framework\InvalidArgumentException
51 | */
52 | public function testWhereNotMethod(): void
53 | {
54 | self::assertEquals(
55 | $this->getExpected('status', 'published'),
56 | $this->getActual('status', 'published'),
57 | );
58 |
59 | self::assertEquals(
60 | $this->getExpected('status', '=', 'published'),
61 | $this->getActual('status', '=', 'published'),
62 | );
63 |
64 | self::assertEquals(
65 | $this->getExpected('views', '>', 1000),
66 | $this->getActual('views', '>', 1000),
67 | );
68 |
69 | self::assertEquals(
70 | $this->getExpected('views', '>=', 1000),
71 | $this->getActual('views', '>=', 1000),
72 | );
73 |
74 | self::assertEquals(
75 | $this->getExpected('views', '<=', 1000),
76 | $this->getActual('views', '<=', 1000),
77 | );
78 |
79 | self::assertEquals(
80 | $this->getExpected('content', 'like', 'hello'),
81 | $this->getActual('content', 'like', 'hello'),
82 | );
83 |
84 | self::assertEquals(
85 | $this->getExpected('website', 'exists', true),
86 | $this->getActual('website', 'exists', true),
87 | );
88 |
89 | self::assertEquals(
90 | $this->getExpected('website', 'exists', false),
91 | $this->getActual('website', 'exists', false),
92 | );
93 | }
94 |
95 | protected function getExpected(
96 | string $name,
97 | string $operator = '=',
98 | mixed $value = null,
99 | ): array {
100 | $query = $this->getQueryArray();
101 |
102 | if (!in_array(
103 | $operator,
104 | $this->operators,
105 | true,
106 | )) {
107 | $value = $operator;
108 | $operator = '=';
109 | }
110 |
111 | $must = [];
112 | $must_not = [];
113 |
114 | if ($operator === '=') {
115 | $must_not[] = ['term' => [$name => $value]];
116 | }
117 |
118 | if ($operator === '>') {
119 | $must_not[] = ['range' => [$name => ['gt' => $value]]];
120 | }
121 |
122 | if ($operator === '>=') {
123 | $must_not[] = ['range' => [$name => ['gte' => $value]]];
124 | }
125 |
126 | if ($operator === '<') {
127 | $must_not[] = ['range' => [$name => ['lt' => $value]]];
128 | }
129 |
130 | if ($operator === '<=') {
131 | $must_not[] = ['range' => [$name => ['lte' => $value]]];
132 | }
133 |
134 | if ($operator === 'like') {
135 | $must_not[] = ['match' => [$name => $value]];
136 | }
137 |
138 | if ($operator === 'exists') {
139 | if ($value) {
140 | $must_not[] = ['exists' => ['field' => $name]];
141 | } else {
142 | $must[] = ['exists' => ['field' => $name]];
143 | }
144 | }
145 |
146 | // Build query body
147 |
148 | $bool = [];
149 |
150 | if (count($must)) {
151 | $bool['must'] = $must;
152 | }
153 |
154 | if (count($must_not)) {
155 | $bool['must_not'] = $must_not;
156 | }
157 |
158 | $query['body']['query']['bool'] = $bool;
159 |
160 | return $query;
161 | }
162 |
163 | /**
164 | * @throws \PHPUnit\Framework\InvalidArgumentException
165 | * @throws ClassAlreadyExistsException
166 | * @throws ClassIsFinalException
167 | * @throws ClassIsReadonlyException
168 | * @throws DuplicateMethodException
169 | * @throws InvalidMethodNameException
170 | * @throws OriginalConstructorInvocationRequiredException
171 | * @throws ReflectionException
172 | * @throws RuntimeException
173 | * @throws UnknownTypeException
174 | */
175 | protected function getActual(string $name, string|null $operator = '=', mixed $value = null): array
176 | {
177 | return $this->getQueryObject()->whereNot($name, $operator, $value)->toArray();
178 | }
179 | }
180 |
--------------------------------------------------------------------------------
/tests/WhereTest.php:
--------------------------------------------------------------------------------
1 | ',
29 | '>=',
30 | '<',
31 | '<=',
32 | 'like',
33 | 'exists',
34 | ];
35 |
36 | /**
37 | * Test the where() method.
38 | *
39 | * @throws ClassAlreadyExistsException
40 | * @throws ClassIsFinalException
41 | * @throws ClassIsReadonlyException
42 | * @throws DuplicateMethodException
43 | * @throws ExpectationFailedException
44 | * @throws InvalidArgumentException
45 | * @throws InvalidMethodNameException
46 | * @throws OriginalConstructorInvocationRequiredException
47 | * @throws ReflectionException
48 | * @throws RuntimeException
49 | * @throws UnknownTypeException
50 | * @throws \InvalidArgumentException
51 | * @throws \PHPUnit\Framework\InvalidArgumentException
52 | */
53 | public function testWhereMethod(): void
54 | {
55 | self::assertEquals(
56 | $this->getExpected('status', 'published'),
57 | $this->getActual('status', 'published'),
58 | );
59 |
60 | self::assertEquals(
61 | $this->getExpected('status', '=', 'published'),
62 | $this->getActual('status', '=', 'published'),
63 | );
64 |
65 | self::assertEquals(
66 | $this->getExpected('views', '>', 1000),
67 | $this->getActual('views', '>', 1000),
68 | );
69 |
70 | self::assertEquals(
71 | $this->getExpected('views', '>=', 1000),
72 | $this->getActual('views', '>=', 1000),
73 | );
74 |
75 | self::assertEquals(
76 | $this->getExpected('views', '<=', 1000),
77 | $this->getActual('views', '<=', 1000),
78 | );
79 |
80 | self::assertEquals(
81 | $this->getExpected('content', 'like', 'hello'),
82 | $this->getActual('content', 'like', 'hello'),
83 | );
84 |
85 | self::assertEquals(
86 | $this->getExpected('website', 'exists', true),
87 | $this->getActual('website', 'exists', true),
88 | );
89 |
90 | self::assertEquals(
91 | $this->getExpected('website', 'exists', false),
92 | $this->getActual('website', 'exists', false),
93 | );
94 | }
95 |
96 | /**
97 | * @param mixed|null $value
98 | * @throws ClassAlreadyExistsException
99 | * @throws ClassIsFinalException
100 | * @throws DuplicateMethodException
101 | * @throws InvalidMethodNameException
102 | * @throws OriginalConstructorInvocationRequiredException
103 | * @throws ReflectionException
104 | * @throws RuntimeException
105 | * @throws UnknownTypeException
106 | * @throws \InvalidArgumentException
107 | * @throws \PHPUnit\Framework\InvalidArgumentException
108 | * @throws ClassIsReadonlyException
109 | */
110 | protected function getActual(
111 | string $name,
112 | string $operator = '=',
113 | mixed $value = null,
114 | ): array {
115 | return $this
116 | ->getQueryObject()
117 | ->where($name, $operator, $value)
118 | ->toArray();
119 | }
120 |
121 | /**
122 | * @param mixed|null $value
123 | *
124 | */
125 | protected function getExpected(
126 | string $name,
127 | string $operator = '=',
128 | mixed $value = null,
129 | ): array {
130 | $query = $this->getQueryArray();
131 |
132 | if (!in_array(
133 | $operator,
134 | $this->operators,
135 | true,
136 | )) {
137 | $value = $operator;
138 | $operator = '=';
139 | }
140 |
141 | $filter = [];
142 | $must = [];
143 | $must_not = [];
144 |
145 | if ($operator === '=') {
146 | $filter[] = ['term' => [$name => $value]];
147 | }
148 |
149 | if ($operator === '>') {
150 | $filter[] = ['range' => [$name => ['gt' => $value]]];
151 | }
152 |
153 | if ($operator === '>=') {
154 | $filter[] = ['range' => [$name => ['gte' => $value]]];
155 | }
156 |
157 | if ($operator === '<') {
158 | $filter[] = ['range' => [$name => ['lt' => $value]]];
159 | }
160 |
161 | if ($operator === '<=') {
162 | $filter[] = ['range' => [$name => ['lte' => $value]]];
163 | }
164 |
165 | if ($operator === 'like') {
166 | $must[] = ['match' => [$name => $value]];
167 | }
168 |
169 | if ($operator === 'exists') {
170 | if ($value) {
171 | $must[] = ['exists' => ['field' => $name]];
172 | } else {
173 | $must_not[] = ['exists' => ['field' => $name]];
174 | }
175 | }
176 |
177 | // Build query body
178 |
179 | $bool = [];
180 |
181 | if (count($must)) {
182 | $bool['must'] = $must;
183 | }
184 |
185 | if (count($must_not)) {
186 | $bool['must_not'] = $must_not;
187 | }
188 |
189 | if (count($filter)) {
190 | $bool['filter'] = $filter;
191 | }
192 |
193 | $query['body']['query']['bool'] = $bool;
194 |
195 | return $query;
196 | }
197 | }
198 |
--------------------------------------------------------------------------------
/src/Commands/ReindexCommand.php:
--------------------------------------------------------------------------------
1 | connection = $this->option('connection') ?: null;
66 | $this->size = (int) $this->option('bulk-size');
67 | $this->scroll = (string) $this->option('scroll');
68 |
69 | if ($this->size <= 0) {
70 | $this->warn('Invalid size value');
71 |
72 | return;
73 | }
74 |
75 | $originalIndex = (string) $this->argument('index');
76 | $newIndex = $this->argument('new_index');
77 |
78 | if (!array_key_exists($originalIndex, config('elasticsearch.indices', config('es.indices', [])))) {
79 | $this->warn("Missing configuration for index: {$originalIndex}");
80 |
81 | return;
82 | }
83 |
84 | if (!array_key_exists($newIndex, config('elasticsearch.indices', config('es.indices', [])))) {
85 | $this->warn("Missing configuration for index: {$newIndex}");
86 |
87 | return;
88 | }
89 |
90 | $this->migrate($resolver, $originalIndex, $newIndex);
91 | }
92 |
93 | /**
94 | * Migrate data with Scroll queries & Bulk API
95 | *
96 | * @throws InvalidArgumentException
97 | * @throws JsonException
98 | * @throws RuntimeException
99 | */
100 | public function migrate(
101 | ConnectionResolverInterface $resolver,
102 | string $originalIndex,
103 | string $newIndex,
104 | string|null $scrollId = null,
105 | int $errors = 0,
106 | int $page = 1,
107 | ): void {
108 | $connection = $resolver->connection($this->connection);
109 |
110 | if ($page === 1) {
111 | $pages = (int) ceil(
112 | $connection
113 | ->index($originalIndex)
114 | ->count() / $this->size,
115 | );
116 |
117 | $this->output->progressStart($pages);
118 |
119 | $documents = $connection
120 | ->index($originalIndex)
121 | ->scroll($this->scroll)
122 | ->take($this->size)
123 | ->performSearch();
124 | } else {
125 | $documents = $connection
126 | ->index($originalIndex)
127 | ->scroll($this->scroll)
128 | ->scrollID($scrollId ?: '')
129 | ->performSearch();
130 | }
131 |
132 | if (
133 | isset($documents['hits']['hits']) &&
134 | count($documents['hits']['hits'])
135 | ) {
136 | $data = $documents['hits']['hits'];
137 | $params = [];
138 |
139 | foreach ($data as $row) {
140 | $params['body'][] = [
141 |
142 | 'index' => [
143 | '_index' => $newIndex,
144 | '_type' => $row['_type'],
145 | '_id' => $row['_id'],
146 | ],
147 |
148 | ];
149 |
150 | $params['body'][] = $row['_source'];
151 | }
152 |
153 | $response = $connection->getClient()->bulk($params);
154 |
155 | if (isset($response['errors']) && $response['errors']) {
156 | if (!$this->option('hide-errors')) {
157 | $items = json_encode($response['items']);
158 |
159 | if (!$this->option('skip-errors')) {
160 | $this->warn("\n{$items}");
161 |
162 | return;
163 | }
164 |
165 | $this->warn("\n{$items}");
166 | }
167 |
168 | $errors++;
169 | }
170 |
171 | $this->output->progressAdvance();
172 | } else {
173 | // Reindexing finished
174 | $this->output->progressFinish();
175 |
176 | $total = $connection
177 | ->index($originalIndex)
178 | ->count();
179 |
180 | if ($errors > 0) {
181 | $this->warn("{$total} documents reindexed with {$errors} errors.");
182 |
183 | return;
184 | }
185 |
186 | $this->info("{$total} documents reindexed successfully.");
187 |
188 | return;
189 | }
190 |
191 | $page++;
192 |
193 | $this->migrate(
194 | $resolver,
195 | $originalIndex,
196 | $newIndex,
197 | $documents['_scroll_id'],
198 | $errors,
199 | $page,
200 | );
201 | }
202 |
203 | }
204 |
--------------------------------------------------------------------------------
/src/Classes/Bulk.php:
--------------------------------------------------------------------------------
1 | query = $query;
73 | $this->autocommitAfter = (int) $autocommitAfter;
74 | }
75 |
76 | /**
77 | * Get Bulk body
78 | *
79 | * @return array
80 | */
81 | public function body(): array
82 | {
83 | return $this->body;
84 | }
85 |
86 | /**
87 | * Add pending document for deletion
88 | *
89 | * @return bool
90 | */
91 | public function delete(): bool
92 | {
93 | return $this->action('delete');
94 | }
95 |
96 | /**
97 | * Add pending document abstract action
98 | *
99 | * @param string $actionType
100 | * @param array $data
101 | *
102 | * @return bool
103 | */
104 | public function action(string $actionType, array $data = []): bool
105 | {
106 | $this->body['body'][] = [
107 | $actionType => [
108 | '_index' => $this->getIndex(),
109 | '_type' => $this->getType(),
110 | '_id' => $this->_id,
111 | ],
112 | ];
113 |
114 | if (!empty($data)) {
115 | $this->body['body'][] = $actionType === 'update'
116 | ? ['doc' => $data]
117 | : $data;
118 | }
119 |
120 | $this->operationCount++;
121 |
122 | $this->reset();
123 |
124 | if (
125 | $this->autocommitAfter > 0 &&
126 | $this->operationCount >= $this->autocommitAfter
127 | ) {
128 | return (bool) $this->commit();
129 | }
130 |
131 | return true;
132 | }
133 |
134 | /**
135 | * Get the index name
136 | *
137 | * @return string|null
138 | */
139 | protected function getIndex(): string|null
140 | {
141 | return $this->index ?: $this->query->getIndex();
142 | }
143 |
144 | /**
145 | * Get the type name
146 | *
147 | * @return string|null
148 | * @deprecated Mapping types are deprecated as of Elasticsearch 7.0.0
149 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/removal-of-types.html
150 | */
151 | #[Deprecated(reason: 'Mapping types are deprecated as of Elasticsearch 7.0.0')]
152 | protected function getType(): string|null
153 | {
154 | return $this->type;
155 | }
156 |
157 | /**
158 | * Reset names
159 | *
160 | * @return void
161 | */
162 | public function reset(): void
163 | {
164 | $this->index();
165 | $this->type();
166 | }
167 |
168 | /**
169 | * Set the index name
170 | *
171 | * @param string|null $index
172 | *
173 | * @return $this
174 | */
175 | public function index(string|null $index = null): self
176 | {
177 | $this->index = $index;
178 |
179 | return $this;
180 | }
181 |
182 | /**
183 | * Set the type name
184 | *
185 | * @param string|null $type
186 | *
187 | * @return $this
188 | * @deprecated Mapping types are deprecated as of Elasticsearch 7.0.0
189 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/removal-of-types.html
190 | */
191 | #[Deprecated(reason: 'Mapping types are deprecated as of Elasticsearch 7.0.0')]
192 | public function type(string|null $type = null): self
193 | {
194 | $this->type = $type;
195 |
196 | return $this;
197 | }
198 |
199 | /**
200 | * Commit all pending operations
201 | *
202 | * @return array|null
203 | */
204 | public function commit(): ?array
205 | {
206 | if (empty($this->body)) {
207 | return null;
208 | }
209 |
210 | $result = $this
211 | ->query
212 | ->getConnection()
213 | ->getClient()
214 | ->bulk($this->body);
215 |
216 | $this->operationCount = 0;
217 | $this->body = [];
218 |
219 | return $result;
220 | }
221 |
222 | /**
223 | * Just an alias for _id() method
224 | *
225 | * @param string|null $_id
226 | *
227 | * @return $this
228 | */
229 | public function id(string|null $_id = null): self
230 | {
231 | return $this->_id($_id);
232 | }
233 |
234 | /**
235 | * Filter by _id
236 | *
237 | * @param string|null $_id
238 | *
239 | * @return $this
240 | */
241 | public function _id(string|null $_id = null): self
242 | {
243 | $this->_id = $_id;
244 |
245 | return $this;
246 | }
247 |
248 | /**
249 | * Add pending document for insert
250 | *
251 | * @param array $data
252 | *
253 | * @return bool
254 | */
255 | public function insert(array $data = []): bool
256 | {
257 | return $this->action('index', $data);
258 | }
259 |
260 | /**
261 | * Add pending document for update
262 | *
263 | * @param array $data
264 | *
265 | * @return bool
266 | */
267 | public function update(array $data = []): bool
268 | {
269 | return $this->action('update', $data);
270 | }
271 | }
272 |
--------------------------------------------------------------------------------
/src/Concerns/AppliesScopes.php:
--------------------------------------------------------------------------------
1 |
26 | */
27 | protected array $removedScopes = [];
28 |
29 | /**
30 | * Holds all scopes applied to the query.
31 | *
32 | * @var array
33 | */
34 | protected array $scopes = [];
35 |
36 | /**
37 | * Apply the scopes to the Elasticsearch query instance and return it.
38 | *
39 | * @return $this
40 | */
41 | public function applyScopes(): static
42 | {
43 | if (!$this->scopes) {
44 | return $this;
45 | }
46 |
47 | $query = clone $this;
48 |
49 | foreach ($this->scopes as $identifier => $scope) {
50 | if (!isset($query->scopes[$identifier])) {
51 | continue;
52 | }
53 |
54 | $query->callScope(function (Query $query) use ($scope) {
55 | // If the scope is a Closure we will just go ahead and call the
56 | // scope with the builder instance.
57 | if ($scope instanceof Closure) {
58 | $scope($query);
59 | }
60 |
61 | // If the scope is a scope object, we will call the apply method
62 | // on this scope passing in the query and the model instance.
63 | // After we run all of these scopes we will return the query
64 | // instance to the outside caller.
65 | if ($scope instanceof ScopeInterface) {
66 | $scope->apply($query, $this->getModel());
67 | }
68 | });
69 | }
70 |
71 | return $query;
72 | }
73 |
74 | /**
75 | * Apply the given scope on the current builder instance.
76 | *
77 | * @param callable $scope
78 | * @param array $parameters
79 | *
80 | * @return $this
81 | */
82 | protected function callScope(callable $scope, array $parameters = []): static
83 | {
84 | array_unshift($parameters, $this);
85 | $scope(...array_values($parameters));
86 |
87 | return $this;
88 | }
89 |
90 | /**
91 | * Determine if the given model has a scope.
92 | *
93 | * @param string $scope
94 | *
95 | * @return bool
96 | */
97 | public function hasNamedScope(string $scope): bool
98 | {
99 | return $this->getModel()->hasNamedScope($scope);
100 | }
101 |
102 | /**
103 | * Get an array of global scopes that were removed from the query.
104 | *
105 | * @return string[]
106 | */
107 | public function removedScopes(): array
108 | {
109 | return $this->removedScopes;
110 | }
111 |
112 | /**
113 | * Call the given local model scopes.
114 | *
115 | * @param array|string $scopes
116 | *
117 | * @return $this
118 | */
119 | public function scopes(array|string $scopes): static
120 | {
121 | $query = $this;
122 |
123 | foreach (Arr::wrap($scopes) as $scope => $parameters) {
124 | // If the scope key is an integer, then the scope was passed as the
125 | // value and the parameter list is empty, so we will format the
126 | // scope name and these parameters here. Then, we'll be ready to
127 | // call the scope on the model.
128 | if (is_int($scope)) {
129 | [$scope, $parameters] = [$parameters, []];
130 | }
131 |
132 | // Next we'll pass the scope callback to the callScope method which
133 | // will take care of grouping the conditions properly so the logical
134 | // order doesn't get messed up when adding scopes.
135 | // Then we'll return out the query.
136 | $query = $query->callNamedScope(
137 | $scope,
138 | (array) $parameters,
139 | );
140 | }
141 |
142 | return $query;
143 | }
144 |
145 | /**
146 | * Apply the given named scope on the current query instance.
147 | *
148 | * @param string $scope
149 | * @param array $parameters
150 | *
151 | * @return $this
152 | */
153 | protected function callNamedScope(
154 | string $scope,
155 | array $parameters = [],
156 | ): static {
157 | return $this->callScope(fn(mixed ...$parameters): mixed => $this
158 | ->getModel()
159 | ->callNamedScope(
160 | $scope,
161 | $parameters,
162 | ), $parameters);
163 | }
164 |
165 | /**
166 | * Register a new global scope.
167 | *
168 | * @param string $identifier
169 | * @param Closure|ScopeInterface $scope
170 | *
171 | * @return $this
172 | */
173 | public function withGlobalScope(
174 | string $identifier,
175 | ScopeInterface|Closure $scope,
176 | ): static {
177 | $this->scopes[$identifier] = $scope;
178 |
179 | if (method_exists($scope, 'extend')) {
180 | $scope->extend($this);
181 | }
182 |
183 | return $this;
184 | }
185 |
186 | /**
187 | * Remove all or passed registered global scopes.
188 | *
189 | * @param ScopeInterface[]|null $scopes
190 | *
191 | * @return $this
192 | */
193 | public function withoutGlobalScopes(array|null $scopes = null): static
194 | {
195 | if (!is_array($scopes)) {
196 | $scopes = array_keys($this->scopes);
197 | }
198 |
199 | foreach ($scopes as $scope) {
200 | $this->withoutGlobalScope($scope);
201 | }
202 |
203 | return $this;
204 | }
205 |
206 | /**
207 | * Remove a registered global scope.
208 | *
209 | * @param string|ScopeInterface $scope
210 | *
211 | * @return $this
212 | */
213 | public function withoutGlobalScope(ScopeInterface|string $scope): static
214 | {
215 | if (!is_string($scope)) {
216 | $scope = get_class($scope);
217 | }
218 |
219 | unset($this->scopes[$scope]);
220 |
221 | $this->removedScopes[] = $scope;
222 |
223 | return $this;
224 | }
225 | }
226 |
--------------------------------------------------------------------------------
/config/elasticsearch.php:
--------------------------------------------------------------------------------
1 | env('ELASTIC_CONNECTION', 'default'),
17 |
18 | /*
19 | |--------------------------------------------------------------------------
20 | | Elasticsearch Connections
21 | |--------------------------------------------------------------------------
22 | |
23 | | Here are each of the Elasticsearch connections setup for your application.
24 | | Of course, examples of configuring each Elasticsearch platform.
25 | |
26 | */
27 | 'connections' => [
28 | 'default' => [
29 | 'hosts' => env('ELASTICSEARCH_HOST', 'http://localhost:9200'),
30 | 'index' => env('ELASTICSEARCH_INDEX', 'my_index'),
31 | ],
32 |
33 | //'authenticated' => [
34 | // 'hosts' => env('ELASTICSEARCH_HOST', 'https://localhost:9200'),
35 | // 'index' => env('ELASTICSEARCH_INDEX', 'my_index'),
36 | // 'sslVerification' => false,
37 | // 'basicAuthentication' => [
38 | // 'username' => env('ELASTICSEARCH_USERNAME', 'elastic'),
39 | // 'password' => env('ELASTICSEARCH_PASSWORD'),
40 | // ],
41 | //],
42 | //
43 | //'authenticated_with_apikey' => [
44 | // 'hosts' => env('ELASTICSEARCH_HOST', 'https://localhost:9200'),
45 | // 'index' => env('ELASTICSEARCH_INDEX', 'my_index'),
46 | // 'apiKey' => [
47 | // 'id' => env('ELASTICSEARCH_API_KEY_ID'),
48 | // 'apiKey' => env('ELASTICSEARCH_API_KEY'),
49 | // ],
50 | //],
51 | //
52 | //'multiple_hosts' => [
53 | // 'hosts' => env(
54 | // 'ELASTICSEARCH_HOST',
55 | // 'https://first.host.tld:9200,https://second.host.tld:9200,https://third.host.tld:9200'
56 | // ),
57 | //],
58 | //
59 | //'various_settings' => [
60 | //
61 | // // Set Elastic Cloud ID to connect to Elastic Cloud
62 | // 'elasticCloudId' => env('ELASTICSEARCH_CLOUD_ID'),
63 | //
64 | // // Set number or retries (default is equal to number of nodes)
65 | // 'retries' => 3,
66 | //
67 | // // Set the selector algorithm
68 | // 'selector' => true,
69 | //
70 | // // Whether to sniff the connection on startup
71 | // 'sniffOnStart' => false,
72 | //
73 | // 'sslCert' => [
74 | //
75 | // // The name of a file containing a PEM-formatted public TLS certificate
76 | // 'cert' => env('ELASTICSEARCH_TLS_CERT_PATH'),
77 | //
78 | // // Optional passphrase for the certificate
79 | // 'password' => env('ELASTICSEARCH_TLS_CERT_PASSPHRASE'),
80 | // ],
81 | //
82 | // 'sslKey' => [
83 | //
84 | // // The name of a file containing a private TLS key
85 | // 'key' => env('ELASTICSEARCH_TLS_KEY_PATH'),
86 | //
87 | // // Optional passphrase used to decrypt the private TLS key
88 | // 'password' => env('ELASTICSEARCH_TLS_KEY_PASSPHRASE'),
89 | // ],
90 | //
91 | // // Enable or disable verification of the SSL certificate
92 | // 'sslVerification' => false,
93 | //
94 | // // Set or disable the x-elastic-client-meta header
95 | // 'elasticMetaHeader' => true,
96 | //
97 | // // Include the port in Host header.
98 | // // See: https://github.com/elastic/elasticsearch-php/issues/993
99 | // 'includePortInHostHeader' => true,
100 | //],
101 | ],
102 |
103 | /*
104 | |--------------------------------------------------------------------------
105 | | Elasticsearch Indices
106 | |--------------------------------------------------------------------------
107 | |
108 | | Here you can define your indices, with separate settings and mappings.
109 | | Edit settings and mappings and run 'php artisan es:index:update' to update
110 | | indices on elasticsearch server.
111 | |
112 | | 'my_index' is just for test. Replace it with a real index name.
113 | |
114 | */
115 | 'indices' => [
116 | 'my_index_1' => [
117 | 'aliases' => [
118 | 'my_index',
119 | ],
120 |
121 | 'settings' => [
122 | 'number_of_shards' => 1,
123 | 'number_of_replicas' => 0,
124 | 'index.mapping.ignore_malformed' => false,
125 |
126 | 'analysis' => [
127 | 'filter' => [
128 | 'english_stop' => [
129 | 'type' => 'stop',
130 | 'stopwords' => '_english_',
131 | ],
132 | 'english_keywords' => [
133 | 'type' => 'keyword_marker',
134 | 'keywords' => ['example'],
135 | ],
136 | 'english_stemmer' => [
137 | 'type' => 'stemmer',
138 | 'language' => 'english',
139 | ],
140 | 'english_possessive_stemmer' => [
141 | 'type' => 'stemmer',
142 | 'language' => 'possessive_english',
143 | ],
144 | ],
145 | 'analyzer' => [
146 | 'rebuilt_english' => [
147 | 'tokenizer' => 'standard',
148 | 'filter' => [
149 | 'english_possessive_stemmer',
150 | 'lowercase',
151 | 'english_stop',
152 | 'english_keywords',
153 | 'english_stemmer',
154 | ],
155 | ],
156 | ],
157 | ],
158 | ],
159 |
160 | 'mappings' => [
161 | 'posts' => [
162 | 'properties' => [
163 | 'title' => [
164 | 'type' => 'text',
165 | 'analyzer' => 'english',
166 | ],
167 | ],
168 | ],
169 | ],
170 | ],
171 | ],
172 | ];
173 |
--------------------------------------------------------------------------------
/src/ScoutEngine.php:
--------------------------------------------------------------------------------
1 | [],
37 | ];
38 |
39 | $models->each(function (Model $model) use (&$params) {
40 | $params['body'][] = [
41 | 'delete' => [
42 | '_id' => $model->getKey(),
43 | '_index' => $this->index,
44 | ],
45 | ];
46 | });
47 |
48 | $this->client->bulk($params);
49 | }
50 |
51 | /**
52 | * Flush all of the model's records from the engine.
53 | *
54 | * @param Model $model
55 | *
56 | * @return void
57 | */
58 | public function flush($model): void
59 | {
60 | $this->client->deleteByQuery([
61 | 'index' => $this->index,
62 | 'body' => [
63 | 'query' => [
64 | 'match_all' => [],
65 | ],
66 | ],
67 | ]);
68 | }
69 |
70 | /**
71 | * Get the total count from a raw result returned by the engine.
72 | *
73 | * @param mixed $results
74 | *
75 | * @return int
76 | */
77 | public function getTotalCount($results): int
78 | {
79 | return $results['hits']['total'];
80 | }
81 |
82 | /**
83 | * @param mixed $results
84 | *
85 | * @return Collection
86 | */
87 | public function mapIds($results): Collection
88 | {
89 | return new Collection([]);
90 | }
91 |
92 | /**
93 | * Perform the given search on the engine.
94 | *
95 | * @param Builder $builder
96 | * @param int $perPage
97 | * @param int $page
98 | *
99 | * @return array|callable
100 | */
101 | public function paginate(Builder $builder, $perPage, $page): array|callable
102 | {
103 | $result = $this->performSearch($builder, [
104 | 'numericFilters' => $this->filters($builder),
105 | 'from' => (($page * $perPage) - $perPage),
106 | 'size' => $perPage,
107 | ]);
108 |
109 | assert(is_array($result));
110 |
111 | $result['nbPages'] = $result['hits']['total'] / $perPage;
112 |
113 | return $result;
114 | }
115 |
116 | /**
117 | * Perform the given search on the engine.
118 | *
119 | * @param Builder $builder
120 | * @param array $options
121 | *
122 | * @return array|callable
123 | */
124 | protected function performSearch(
125 | Builder $builder,
126 | array $options = [],
127 | ): callable|array {
128 | $params = [
129 | 'index' => $this->index,
130 | 'body' => [
131 | 'query' => [
132 | 'bool' => [
133 | 'must' => [
134 | [
135 | 'query_string' => [
136 | 'query' => $builder->query,
137 | ],
138 | ],
139 | ],
140 | ],
141 | ],
142 | ],
143 | ];
144 |
145 | if (isset($options['from'])) {
146 | $params['body']['from'] = $options['from'];
147 | }
148 |
149 | if (isset($options['size'])) {
150 | $params['body']['size'] = $options['size'];
151 | }
152 |
153 | if (
154 | isset($options['numericFilters']) &&
155 | count($options['numericFilters'])
156 | ) {
157 | $params['body']['query']['bool']['must'] = array_merge(
158 | $params['body']['query']['bool']['must'],
159 | $options['numericFilters'],
160 | );
161 | }
162 |
163 | return $this->client->search($params);
164 | }
165 |
166 | /**
167 | * Perform the given search on the engine.
168 | *
169 | * @param Builder $builder
170 | *
171 | * @return array|callable
172 | */
173 | public function search(Builder $builder): array|callable
174 | {
175 | return $this->performSearch($builder, array_filter([
176 | 'numericFilters' => $this->filters($builder),
177 | 'size' => $builder->limit,
178 | ]));
179 | }
180 |
181 | /**
182 | * Get the filter array for the query.
183 | *
184 | * @param Builder $builder
185 | *
186 | * @return array
187 | */
188 | protected function filters(Builder $builder): array
189 | {
190 | return collect($builder->wheres)
191 | ->map(
192 | /**
193 | * @param mixed $value
194 | * @param int|string $key
195 | *
196 | * @return array
197 | */
198 | static fn(mixed $value, int|string $key): array
199 | => [
200 | 'match_phrase' => [$key => $value],
201 | ],
202 | )
203 | ->values()
204 | ->all();
205 | }
206 |
207 | /**
208 | * Map the given results to instances of the given model.
209 | *
210 | * @param Builder $builder
211 | * @param mixed $results
212 | * @param Model $model
213 | *
214 | * @return Collection
215 | * @throws InvalidArgumentException
216 | */
217 | public function map(Builder $builder, $results, $model): Collection
218 | {
219 | if ((int) $results['hits']['total'] === 0) {
220 | return Collection::make();
221 | }
222 |
223 | $keys = collect($results['hits']['hits'])
224 | ->pluck('_id')
225 | ->values()
226 | ->all();
227 |
228 | $models = $model
229 | ->query()
230 | ->whereIn($model->getKeyName(), $keys)
231 | ->get()
232 | ->keyBy($model->getKeyName());
233 |
234 | $collection = new Collection($results['hits']['hits']);
235 |
236 | return $collection->map(static fn(
237 | array $hit,
238 | )
239 | => $models[$hit['_id']]);
240 | }
241 |
242 | /**
243 | * Update the given model in the index.
244 | *
245 | * @param Collection $models
246 | *
247 | * @return void
248 | */
249 | public function update($models): void
250 | {
251 | $params = [
252 | 'body' => [],
253 | ];
254 |
255 | $models->each(function (Model $model) use (&$params) {
256 | $params['body'][] = [
257 | 'update' => [
258 | '_id' => $model->getKey(),
259 | '_index' => $this->index,
260 | ],
261 | ];
262 |
263 | assert(is_callable([$model, 'toSearchableArray']));
264 |
265 | $params['body'][] = [
266 | 'doc' => $model->toSearchableArray(),
267 | 'doc_as_upsert' => true,
268 | ];
269 | });
270 |
271 | $this->client->bulk($params);
272 | }
273 |
274 | /**
275 | * @throws InvalidArgumentException
276 | */
277 | public function lazyMap(Builder $builder, $results, $model): LazyCollection
278 | {
279 | if ((int) $results['hits']['total'] === 0) {
280 | return LazyCollection::make();
281 | }
282 |
283 | $keys = collect($results['hits']['hits'])
284 | ->pluck('_id')
285 | ->values()
286 | ->all();
287 |
288 | $models = $model
289 | ->newQuery()
290 | ->whereIn($model->getKeyName(), $keys)
291 | ->get()
292 | ->keyBy($model->getKeyName());
293 |
294 | $collection = new LazyCollection($results['hits']['hits']);
295 |
296 | return $collection->map(static fn(array $hit) => $models[$hit['_id']]);
297 | }
298 |
299 | public function createIndex($name, array $options = []): void
300 | {
301 | $this->client->indices()->create([
302 | 'index' => $name,
303 | 'body' => $options,
304 | ]);
305 | }
306 |
307 | public function deleteIndex($name): void
308 | {
309 | $this->client->indices()->delete([
310 | 'index' => $name,
311 | ]);
312 | }
313 | }
314 |
--------------------------------------------------------------------------------
/src/ElasticsearchServiceProvider.php:
--------------------------------------------------------------------------------
1 | configure();
49 |
50 | // Enable automatic connection resolution in all models
51 | Model::setConnectionResolver(
52 | $this->app->make(
53 | ConnectionResolverInterface::class,
54 | ),
55 | );
56 |
57 | // Enable event dispatching in all models
58 | Model::setEventDispatcher(
59 | $this->app->make(
60 | Dispatcher::class,
61 | ),
62 | );
63 |
64 | // TODO: Remove in next major version
65 | /** @noinspection PhpDeprecationInspection */
66 | Connection::setConnectionResolver(
67 | $this->app->make(
68 | ConnectionResolverInterface::class,
69 | ),
70 | );
71 |
72 | // Register the Laravel Scout Engine
73 | $this->registerScoutEngine();
74 | }
75 |
76 |
77 | /**
78 | * @throws BindingResolutionException
79 | */
80 | protected function configure(): void
81 | {
82 | if (file_exists($this->packageConfigPath('es.php'))) {
83 | $configPath = $this->packageConfigPath('es.php');
84 | @trigger_error(
85 | "Since matchory/elasticsearch 3.0.0: The 'es.php' configuration file is deprecated. " .
86 | "Use 'elasticsearch.php' instead.",
87 | E_USER_DEPRECATED,
88 | );
89 | } else {
90 | $configPath = $this->packageConfigPath('elasticsearch.php');
91 | }
92 |
93 | $configKey = basename($configPath, '.php');
94 |
95 | $this->mergeConfigFrom($configPath, $configKey);
96 | $this->mergeLoggingChannelsFrom($this->packageConfigPath('logging.php'));
97 | $this->publishes([
98 | $this->packageConfigPath() => config_path(),
99 | ], "{$configKey}.config");
100 | }
101 |
102 | protected function registerScoutEngine(): void
103 | {
104 | // Resolve Laravel Scout engine.
105 | if (!class_exists(EngineManager::class)) {
106 | return;
107 | }
108 |
109 | try {
110 | $this->app
111 | ->make(EngineManager::class)
112 | ->extend('elasticsearch', function () {
113 | $connectionName = Config::get('scout.elasticsearch.connection');
114 | $config = Config::get("elasticsearch.connections.{$connectionName}");
115 | $elastic = ElasticBuilder::create()
116 | ->setHosts($config['servers'])
117 | ->build();
118 |
119 | return new ScoutEngine($elastic, $config['index']);
120 | });
121 | } catch (BindingResolutionException) {
122 | // Class is not resolved.
123 | // Laravel Scout service provider was not loaded yet.
124 | }
125 | }
126 |
127 | /**
128 | * Register any application services.
129 | *
130 | * @return void
131 | * @throws LogicException
132 | */
133 | public function register(): void
134 | {
135 | Model::clearBootedModels();
136 |
137 | $this->registerCommands();
138 | $this->registerLogger();
139 | $this->registerClientFactory();
140 | $this->registerConnectionResolver();
141 | $this->registerDefaultConnection();
142 | }
143 |
144 | protected function registerCommands(): void
145 | {
146 | if (!$this->app->runningInConsole()) {
147 | return;
148 | }
149 |
150 | // Registering commands
151 | $this->commands([
152 | ListIndicesCommand::class,
153 | CreateIndexCommand::class,
154 | UpdateIndexCommand::class,
155 | DropIndexCommand::class,
156 | ReindexCommand::class,
157 | ]);
158 | }
159 |
160 | /**
161 | * Bind the Elasticsearch logger.
162 | *
163 | * @return void
164 | */
165 | protected function registerLogger(): void
166 | {
167 | $this->app->bind(
168 | 'elasticsearch.logger',
169 | fn(Application $app)
170 | => $app
171 | ->make(LogManager::class)
172 | ->channel('elasticsearch'),
173 | );
174 | }
175 |
176 | /**
177 | * @throws LogicException
178 | */
179 | protected function registerClientFactory(): void
180 | {
181 | // Bind our default client factory on the container, so users may
182 | // override it if they need to build their client in a specific way
183 | $this->app->singleton(
184 | ClientFactoryInterface::class,
185 | ClientFactory::class,
186 | );
187 |
188 | $this->app->bind(ClientFactory::class, fn(Application $app) => new ClientFactory(
189 | $app->make('elasticsearch.logger'),
190 | ));
191 |
192 | $this->app->alias(
193 | ClientFactoryInterface::class,
194 | 'elasticsearch.factory',
195 | );
196 | }
197 |
198 | /**
199 | * @throws LogicException
200 | */
201 | protected function registerConnectionResolver(): void
202 | {
203 | // Bind the connection manager for the resolver interface as a singleton
204 | // on the container, so we have a single instance at all times
205 | $this->app->singleton(
206 | ConnectionResolverInterface::class,
207 | function (Application $app) {
208 | $configuration = Config::get('elasticsearch', Config::get('es', []));
209 | $factory = $app->make(ClientFactoryInterface::class);
210 | $cache = $app->bound(CacheInterface::class)
211 | ? $app->make(CacheInterface::class)
212 | : null;
213 |
214 | return new ConnectionManager(
215 | $configuration,
216 | $factory,
217 | $cache,
218 | );
219 | },
220 | );
221 |
222 | $this->app->alias(
223 | ConnectionResolverInterface::class,
224 | 'elasticsearch.resolver',
225 | );
226 |
227 | $this->app->alias(
228 | ConnectionResolverInterface::class,
229 | 'elasticsearch',
230 | );
231 |
232 | $this->app->alias(
233 | ConnectionResolverInterface::class,
234 | 'es',
235 | );
236 |
237 | $this->app->beforeResolving('es', function () {
238 | @trigger_error(
239 | "Since matchory/elasticsearch 3.0.0: The 'es' alias is deprecated. " .
240 | "Use 'elasticsearch' instead.",
241 | E_USER_DEPRECATED,
242 | );
243 | });
244 | }
245 |
246 | /**
247 | * @throws LogicException
248 | */
249 | protected function registerDefaultConnection(): void
250 | {
251 | // Bind the default connection separately
252 | $this->app->singleton(
253 | ConnectionInterface::class,
254 | fn(Application $app): ConnectionInterface
255 | => $app
256 | ->make(ConnectionResolverInterface::class)
257 | ->connection(),
258 | );
259 |
260 | $this->app->alias(ConnectionInterface::class, 'elasticsearch.connection');
261 | }
262 |
263 | /**
264 | * @throws BindingResolutionException
265 | */
266 | private function mergeLoggingChannelsFrom(string $file): void
267 | {
268 | if (!($this->app instanceof CachesConfiguration && $this->app->configurationIsCached())) {
269 | $packageLoggingConfig = require $file;
270 |
271 | $config = $this->app->make('config');
272 | $config->set(
273 | 'logging.channels',
274 | array_merge(
275 | $packageLoggingConfig['channels'] ?? [],
276 | $config->get('logging.channels', []),
277 | ),
278 | );
279 | }
280 | }
281 |
282 | private function packageConfigPath(string $path = ''): string
283 | {
284 | return dirname(__DIR__) . '/config' . ($path ? '/' . $path : $path);
285 | }
286 | }
287 |
--------------------------------------------------------------------------------
/src/Query.php:
--------------------------------------------------------------------------------
1 | */
41 | use ExecutesQueries;
42 | use AppliesScopes;
43 | use BuildsFluentQueries;
44 | use ForwardsCalls;
45 | use ManagesIndices;
46 | use ExplainsQueries;
47 |
48 | public const DEFAULT_CACHE_PREFIX = 'elasticsearch';
49 |
50 | public const DEFAULT_LIMIT = 10;
51 |
52 | public const DEFAULT_OFFSET = 0;
53 |
54 | public const EQ = self::OPERATOR_EQUAL;
55 |
56 | public const EXISTS = self::OPERATOR_EXISTS;
57 |
58 | public const FIELD_AGGS = 'aggs';
59 |
60 | protected const FIELD_HIGHLIGHT = '_highlight';
61 |
62 | protected const FIELD_HITS = 'hits';
63 |
64 | protected const FIELD_ID = '_id';
65 |
66 | protected const FIELD_INDEX = '_index';
67 |
68 | protected const FIELD_NESTED_HITS = 'hits';
69 |
70 | protected const FIELD_QUERY = 'query';
71 |
72 | protected const FIELD_SCORE = '_score';
73 |
74 | protected const FIELD_SORT = 'sort';
75 |
76 | protected const FIELD_SOURCE = '_source';
77 |
78 | protected const FIELD_TYPE = '_type';
79 |
80 | public const GT = self::OPERATOR_GREATER_THAN;
81 |
82 | public const GTE = self::OPERATOR_GREATER_THAN_OR_EQUAL;
83 |
84 | public const LIKE = self::OPERATOR_LIKE;
85 |
86 | public const LT = self::OPERATOR_LOWER_THAN;
87 |
88 | public const LTE = self::OPERATOR_LOWER_THAN_OR_EQUAL;
89 |
90 | public const NEQ = self::OPERATOR_NOT_EQUAL;
91 |
92 | public const OPERATOR_EQUAL = '=';
93 |
94 | public const OPERATOR_EXISTS = 'exists';
95 |
96 | public const OPERATOR_GREATER_THAN = '>';
97 |
98 | public const OPERATOR_GREATER_THAN_OR_EQUAL = '>=';
99 |
100 | public const OPERATOR_LIKE = 'like';
101 |
102 | public const OPERATOR_LOWER_THAN = '<';
103 |
104 | public const OPERATOR_LOWER_THAN_OR_EQUAL = '<=';
105 |
106 | public const OPERATOR_NOT_EQUAL = '!=';
107 |
108 | public const PARAM_BODY = 'body';
109 |
110 | public const PARAM_CLIENT = 'client';
111 |
112 | public const PARAM_CLIENT_IGNORE = 'ignore';
113 |
114 | public const PARAM_FROM = 'from';
115 |
116 | public const PARAM_INDEX = 'index';
117 |
118 | public const PARAM_SCROLL = 'scroll';
119 |
120 | public const PARAM_SCROLL_ID = 'scroll_id';
121 |
122 | public const PARAM_SEARCH_TYPE = 'search_type';
123 |
124 | public const PARAM_SIZE = 'size';
125 |
126 | public const REGEXP_FLAG_ALL = 1;
127 |
128 | public const REGEXP_FLAG_ANYSTRING = 16;
129 |
130 | public const REGEXP_FLAG_COMPLEMENT = 2;
131 |
132 | public const REGEXP_FLAG_INTERSECTION = 8;
133 |
134 | public const REGEXP_FLAG_INTERVAL = 4;
135 |
136 | public const SOURCE_EXCLUDES = 'excludes';
137 |
138 | public const SOURCE_INCLUDES = 'includes';
139 |
140 | /**
141 | * @var array{
142 | * includes: list,
143 | * excludes: list,
144 | * }
145 | */
146 | protected static array $defaultSource = [
147 | self::SOURCE_INCLUDES => [],
148 | self::SOURCE_EXCLUDES => [],
149 | ];
150 |
151 | /**
152 | * Elastic model instance.
153 | *
154 | * @psalm-var T
155 | */
156 | private Model $model;
157 |
158 | /**
159 | * Elasticsearch connection instance
160 | * =================================
161 | * This connection instance will receive any unresolved method calls from
162 | * the query, effectively acting as a proxy: The connection itself proxies
163 | * to the Elasticsearch client instance.
164 | *
165 | * @var ConnectionInterface
166 | */
167 | protected ConnectionInterface $connection;
168 |
169 | /**
170 | * Creates a new query builder instance.
171 | *
172 | * @param ConnectionInterface $connection Elasticsearch Connection the query
173 | * builder uses.
174 | * @param T|null $model Model instance the query builder
175 | * @noinspection PhpDocSignatureInspection
176 | */
177 | public function __construct(
178 | ConnectionInterface $connection,
179 | Model|null $model = null,
180 | ) {
181 | $this->connection = $connection;
182 |
183 | /**
184 | * We set a plain model here so there's always a model instance set.
185 | * This avoids errors in methods that rely on a model.
186 | *
187 | * @psalm-suppress PossiblyInvalidPropertyAssignmentValue
188 | */
189 | $this->model = $model ?? new Model();
190 | }
191 |
192 | /**
193 | * Adds a ".keyword" suffix to the given field name. This is useful for
194 | * sorting and aggregating on keyword fields.
195 | *
196 | * @param string $field
197 | * @return string
198 | */
199 | public static function asKeyword(string $field): string
200 | {
201 | return rtrim($field, '.') . '.keyword';
202 | }
203 |
204 | /**
205 | * Proxies to the collection iterator, allowing to iterate the query builder
206 | * directly as though it were a result collection.
207 | *
208 | * @inheritDoc
209 | */
210 | final public function getIterator(): ArrayIterator
211 | {
212 | return $this->get()->getIterator();
213 | }
214 |
215 | /**
216 | * Forwards calls to the model instance. If the called method is a scope,
217 | * it will be applied to the query.
218 | *
219 | * @param string $method Name of the called method.
220 | * @param array $parameters Parameters passed to the method.
221 | *
222 | * @return $this Query builder instance.
223 | * @throws BadMethodCallException
224 | */
225 | public function __call(string $method, array $parameters): self
226 | {
227 | if ($this->hasNamedScope($method)) {
228 | return $this->callNamedScope($method, $parameters);
229 | }
230 |
231 | if (!method_exists($this->getModel(), $method)) {
232 | throw new BadMethodCallException(
233 | "Method {$method} does not exist.",
234 | );
235 | }
236 |
237 | return $this->forwardCallTo(
238 | $this->getModel(),
239 | $method,
240 | $parameters,
241 | );
242 | }
243 |
244 | /**
245 | * Retrieves the instance of the model the query is scoped to. It is set to
246 | * the model that initiated a query, but defaults to the Model class itself
247 | * if the query builder is used without models.
248 | *
249 | * @return T Model instance used for the current query.
250 | * @noinspection PhpDocSignatureInspection
251 | */
252 | public function getModel(): Model
253 | {
254 | return $this->model;
255 | }
256 |
257 | /**
258 | * Sets the model the query is based on. Any results will be casted to this
259 | * model. If no model is set, a plain model instance will be used.
260 | *
261 | * @template TModel of Model
262 | *
263 | * @param Model $model Model to use for the current query.
264 | * @psalm-param TModel $model
265 | *
266 | * @return static Query builder instance for chaining.
267 | */
268 | public function setModel(Model $model): static
269 | {
270 | /** @var static $query */
271 | $query = clone $this;
272 | $query->connection = $this->getConnection();
273 | $query->model = $model;
274 |
275 | return $query;
276 | }
277 |
278 | /**
279 | * Retrieves the underlying Elasticsearch connection.
280 | *
281 | * @return ConnectionInterface Connection instance.
282 | * @see ConnectionInterface
283 | * @see Connection
284 | */
285 | public function getConnection(): ConnectionInterface
286 | {
287 | return $this->connection;
288 | }
289 |
290 | /**
291 | * Retrieves the underlying Elasticsearch client instance. This can be used
292 | * to work with the Elasticsearch library directly. You should check out its
293 | * documentation for more information.
294 | *
295 | * @return Client Elasticsearch Client instance.
296 | * @see https://www.elastic.co/guide/en/elasticsearch/client/php-api/current/overview.html
297 | * @see Client
298 | */
299 | public function raw(): Client
300 | {
301 | return $this->getConnection()->getClient();
302 | }
303 |
304 | /**
305 | * Converts the query to a JSON string.
306 | *
307 | * @inheritDoc
308 | * @throws JsonException
309 | */
310 | public function toJson($options = 0): string
311 | {
312 | return json_encode(
313 | $this->jsonSerialize(),
314 | JSON_THROW_ON_ERROR | $options,
315 | );
316 | }
317 |
318 | /**
319 | * @inheritDoc
320 | */
321 | public function jsonSerialize(): array
322 | {
323 | return $this->toArray();
324 | }
325 |
326 | /**
327 | * Converts the fluent query into an Elasticsearch query array that can be
328 | * converted into JSON.
329 | *
330 | * @inheritDoc
331 | */
332 | final public function toArray(): array
333 | {
334 | return $this->buildQuery();
335 | }
336 |
337 | /**
338 | * Converts the query into an Elasticsearch query array.
339 | *
340 | * @return array
341 | */
342 | protected function buildQuery(): array
343 | {
344 | $query = $this->applyScopes();
345 |
346 | $params = [
347 | self::PARAM_BODY => $query->getBody(),
348 | self::PARAM_FROM => $query->getSkip(),
349 | self::PARAM_SIZE => $query->getSize(),
350 | ];
351 |
352 | if (count($query->getIgnores())) {
353 | $params[self::PARAM_CLIENT] = [
354 | self::PARAM_CLIENT_IGNORE => $query->ignores,
355 | ];
356 | }
357 |
358 | if ($searchType = $query->getSearchType()) {
359 | $params[self::PARAM_SEARCH_TYPE] = $searchType;
360 | }
361 |
362 | if ($scroll = $query->getScroll()) {
363 | $params[self::PARAM_SCROLL] = $scroll;
364 | }
365 |
366 | if ($index = $query->getIndex()) {
367 | $params[self::PARAM_INDEX] = $index;
368 | }
369 |
370 | return $params;
371 | }
372 | }
373 |
--------------------------------------------------------------------------------
/src/Index.php:
--------------------------------------------------------------------------------
1 | |string|ArrayObject>
112 | */
113 | protected array $aliases = [];
114 |
115 | /**
116 | * Creates a new index instance.
117 | *
118 | * @param string $name Name of the index to create.
119 | * @param callable|null $callback Callback to configure the index before it
120 | * is created. This allows to add additional
121 | * options like shards, replicas or mappings.
122 | */
123 | public function __construct(public string $name, ?callable $callback = null)
124 | {
125 | $this->callback = $callback;
126 | }
127 |
128 | /**
129 | * Retrieves the name of the new index.
130 | *
131 | * @return string
132 | */
133 | public function getName(): string
134 | {
135 | return $this->name;
136 | }
137 |
138 | /**
139 | * An index alias is a secondary name used to refer to one or more existing
140 | * indices. Most Elasticsearch APIs accept an index alias in place of
141 | * an index.
142 | *
143 | * APIs in Elasticsearch accept an index name when working against a
144 | * specific index, and several indices when applicable. The index aliases
145 | * API allows aliasing an index with a name, with all APIs automatically
146 | * converting the alias name to the actual index name. An alias can also be
147 | * mapped to more than one index, and when specifying it, the alias will
148 | * automatically expand to the aliased indices. An alias can also be
149 | * associated with a filter that will automatically be applied when
150 | * searching, and routing values. An alias cannot have the same name as
151 | * an index.
152 | *
153 | * @param string $alias Name of the alias to add.
154 | * @param array|ArrayObject|string|null $options Options to pass to
155 | * the alias.
156 | *
157 | * @return $this
158 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-aliases.html
159 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html#create-index-aliases
160 | */
161 | public function alias(string $alias, mixed $options = null): self
162 | {
163 | if (
164 | $options !== null &&
165 | !is_string($options) &&
166 | !is_array($options)
167 | ) {
168 | throw new TypeError(
169 | 'Alias options may be passed as an array, a string ' .
170 | 'routing key, or literal null.',
171 | );
172 | }
173 |
174 | $this->aliases[$alias] = $options ?? new ArrayObject();
175 |
176 | return $this;
177 | }
178 |
179 | /**
180 | * Creates a new index
181 | *
182 | * @return array
183 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html
184 | */
185 | public function create(): array
186 | {
187 | $configuratorCallback = $this->callback;
188 |
189 | // By passing a callback, users have the possibility to optionally set
190 | // index configuration in a single, fluent command.
191 | // This API is a little unfortunate, so we should refactor that in the
192 | // next major release.
193 | if ($configuratorCallback) {
194 | $configuratorCallback($this);
195 | }
196 |
197 | $params = [
198 | self::PARAM_INDEX => $this->name,
199 | self::PARAM_BODY => [
200 | self::PARAM_SETTINGS => [
201 | self::PARAM_SETTINGS_NUMBER_OF_SHARDS => $this->shards,
202 | self::PARAM_SETTINGS_NUMBER_OF_REPLICAS => $this->replicas,
203 | ],
204 | ],
205 | ];
206 |
207 | if (count($this->ignores) > 0) {
208 | $params[self::PARAM_CLIENT] = [
209 | self::PARAM_CLIENT_IGNORE => $this->ignores,
210 | ];
211 | }
212 |
213 | if (count($this->aliases) > 0) {
214 | $params[self::PARAM_BODY][self::PARAM_ALIASES] = $this->aliases;
215 | }
216 |
217 | if (count($this->mappings) > 0) {
218 | $params[self::PARAM_BODY][self::PARAM_MAPPINGS] = $this->mappings;
219 | }
220 |
221 | return $this
222 | ->getConnection()
223 | ->getClient()
224 | ->indices()
225 | ->create($params);
226 | }
227 |
228 | /**
229 | * Retrieves the Elasticsearch client instance.
230 | *
231 | * @return Client
232 | * @internal
233 | */
234 | public function getClient(): Client
235 | {
236 | return $this->getConnection()->getClient();
237 | }
238 |
239 | /**
240 | * Retrieves the active connection.
241 | *
242 | * @return ConnectionInterface
243 | * @internal
244 | */
245 | public function getConnection(): ConnectionInterface
246 | {
247 | assert($this->connection !== null);
248 |
249 | return $this->connection;
250 | }
251 |
252 | /**
253 | * Sets the active connection on the index.
254 | *
255 | * @param ConnectionInterface $connection
256 | *
257 | * @internal
258 | */
259 | public function setConnection(ConnectionInterface $connection): void
260 | {
261 | $this->connection = $connection;
262 | }
263 |
264 | /**
265 | * Deletes an existing index.
266 | *
267 | * @return array
268 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-delete-index.html
269 | */
270 | public function drop(): array
271 | {
272 | return $this
273 | ->getConnection()
274 | ->getClient()
275 | ->indices()
276 | ->delete([
277 | self::PARAM_INDEX => $this->name,
278 | self::PARAM_CLIENT => [
279 | self::PARAM_CLIENT_IGNORE => $this->ignores,
280 | ],
281 | ]);
282 | }
283 |
284 | /**
285 | * Checks whether an index exists.
286 | *
287 | * @return bool
288 | */
289 | public function exists(): bool
290 | {
291 | return $this
292 | ->getConnection()
293 | ->getClient()
294 | ->indices()
295 | ->exists([
296 | 'index' => $this->name,
297 | ]);
298 | }
299 |
300 | /**
301 | * Alias to the {@see Index::ignores()} method.
302 | *
303 | * @param int ...$statusCodes
304 | *
305 | * @return $this
306 | */
307 | #[Deprecated(replacement: '%class%->ignores(%parametersList%)')]
308 | public function ignore(int ...$statusCodes): self
309 | {
310 | return $this->ignores(...$statusCodes);
311 | }
312 |
313 | /**
314 | * Configures the client to ignore bad HTTP requests.
315 | *
316 | * @param int ...$statusCodes HTTP Status codes to ignore.
317 | *
318 | * @return $this
319 | */
320 | public function ignores(int ...$statusCodes): self
321 | {
322 | $this->ignores = array_unique($statusCodes);
323 |
324 | return $this;
325 | }
326 |
327 | /**
328 | * Sets the fields mappings.
329 | *
330 | * @param array $mappings
331 | *
332 | * @return $this
333 | */
334 | public function mapping(array $mappings = []): self
335 | {
336 | $this->mappings = $mappings;
337 |
338 | return $this;
339 | }
340 |
341 | /**
342 | * The number of replicas each primary shard has. Defaults to `1`.
343 | *
344 | * @param int $replicas Number of replicas to configure.
345 | *
346 | * @return $this
347 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/master/index-modules.html#index-number-of-replicas
348 | */
349 | public function replicas(int $replicas): self
350 | {
351 | $this->replicas = $replicas;
352 |
353 | return $this;
354 | }
355 |
356 | /**
357 | * The number of primary shards that an index should have. Defaults to `1`.
358 | * This setting can only be set at index creation time. It cannot be changed
359 | * on a closed index.
360 | *
361 | * The number of shards are limited to 1024 per index. This limitation is a
362 | * safety limit to prevent accidental creation of indices that can
363 | * destabilize a cluster due to resource allocation. The limit can be
364 | * modified by specifying
365 | * `export ES_JAVA_OPTS="-Des.index.max_number_of_shards=128"` system
366 | * property on every node that is part of the cluster.
367 | *
368 | * @param int $shards Number of shards to configure.
369 | *
370 | * @return $this
371 | * @see https://www.elastic.co/guide/en/elasticsearch/reference/master/index-modules.html#index-number-of-shards
372 | */
373 | public function shards(int $shards): self
374 | {
375 | $this->shards = $shards;
376 |
377 | return $this;
378 | }
379 | }
380 |
--------------------------------------------------------------------------------
/tests/GlobalScopeTest.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 |
13 | declare(strict_types=1);
14 |
15 | namespace Matchory\Elasticsearch\Tests;
16 |
17 | use InvalidArgumentException;
18 | use Matchory\Elasticsearch\Interfaces\ConnectionInterface;
19 | use Matchory\Elasticsearch\Model;
20 | use Matchory\Elasticsearch\Query;
21 | use Matchory\Elasticsearch\Tests\Traits\ESQueryTrait;
22 | use PHPUnit\Framework\Exception;
23 | use PHPUnit\Framework\ExpectationFailedException;
24 | use PHPUnit\Framework\MockObject\ClassAlreadyExistsException;
25 | use PHPUnit\Framework\MockObject\ClassIsFinalException;
26 | use PHPUnit\Framework\MockObject\ClassIsReadonlyException;
27 | use PHPUnit\Framework\MockObject\DuplicateMethodException;
28 | use PHPUnit\Framework\MockObject\InvalidMethodNameException;
29 | use PHPUnit\Framework\MockObject\OriginalConstructorInvocationRequiredException;
30 | use PHPUnit\Framework\MockObject\ReflectionException;
31 | use PHPUnit\Framework\MockObject\RuntimeException;
32 | use PHPUnit\Framework\MockObject\UnknownTypeException;
33 | use PHPUnit\Framework\TestCase;
34 |
35 | use function assert;
36 |
37 | class GlobalScopeTest extends TestCase
38 | {
39 | use ESQueryTrait;
40 |
41 | /**
42 | * @test
43 | * @throws ClassAlreadyExistsException
44 | * @throws ClassIsFinalException
45 | * @throws ClassIsReadonlyException
46 | * @throws DuplicateMethodException
47 | * @throws ExpectationFailedException
48 | * @throws InvalidArgumentException
49 | * @throws InvalidMethodNameException
50 | * @throws OriginalConstructorInvocationRequiredException
51 | * @throws ReflectionException
52 | * @throws RuntimeException
53 | * @throws UnknownTypeException
54 | * @throws \PHPUnit\Framework\InvalidArgumentException
55 | * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException
56 | */
57 | public function getGlobalScope(): void
58 | {
59 | $model = new class extends Model {
60 | public static ConnectionInterface|null $connection = null;
61 |
62 | public static function resolveConnection(
63 | string|null $connection = null,
64 | ): ConnectionInterface {
65 | assert(static::$connection !== null);
66 |
67 | return static::$connection;
68 | }
69 | };
70 | $model::$connection = $this->getConnection();
71 |
72 | $scope = static function (Query $query): void {};
73 |
74 | $model::addGlobalScope('foo', $scope);
75 |
76 | self::assertSame($scope, $model::getGlobalScope(
77 | 'foo',
78 | ));
79 |
80 | self::assertNull($model::getGlobalScope('bar'));
81 | }
82 |
83 | /**
84 | * @test
85 | * @throws ClassAlreadyExistsException
86 | * @throws ClassIsFinalException
87 | * @throws ClassIsReadonlyException
88 | * @throws DuplicateMethodException
89 | * @throws Exception
90 | * @throws ExpectationFailedException
91 | * @throws InvalidArgumentException
92 | * @throws InvalidMethodNameException
93 | * @throws OriginalConstructorInvocationRequiredException
94 | * @throws ReflectionException
95 | * @throws RuntimeException
96 | * @throws UnknownTypeException
97 | * @throws \PHPUnit\Framework\InvalidArgumentException
98 | * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException
99 | */
100 | public function getGlobalScopes(): void
101 | {
102 | $model = new class extends Model {
103 | public static ConnectionInterface|null $connection = null;
104 |
105 | public static function resolveConnection(
106 | string|null $connection = null,
107 | ): ConnectionInterface {
108 | assert(static::$connection !== null);
109 |
110 | return static::$connection;
111 | }
112 | };
113 | $model::$connection = $this->getConnection();
114 |
115 | $scope = static function (Query $query): void {};
116 |
117 | $model::addGlobalScope('foo', $scope);
118 |
119 | self::assertContains($scope, $model->getGlobalScopes());
120 | }
121 |
122 | /**
123 | * @throws ClassAlreadyExistsException
124 | * @throws ClassIsFinalException
125 | * @throws ClassIsReadonlyException
126 | * @throws DuplicateMethodException
127 | * @throws ExpectationFailedException
128 | * @throws InvalidArgumentException
129 | * @throws InvalidMethodNameException
130 | * @throws OriginalConstructorInvocationRequiredException
131 | * @throws ReflectionException
132 | * @throws RuntimeException
133 | * @throws UnknownTypeException
134 | * @throws \PHPUnit\Framework\InvalidArgumentException
135 | * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException
136 | * @test
137 | */
138 | public function addGlobalScope(): void
139 | {
140 | self::assertEquals(
141 | $this->getExpected('views', 500),
142 | $this->getActual('views', 500),
143 | );
144 | }
145 |
146 | /**
147 | * @test
148 | * @throws ClassAlreadyExistsException
149 | * @throws ClassIsFinalException
150 | * @throws ClassIsReadonlyException
151 | * @throws DuplicateMethodException
152 | * @throws ExpectationFailedException
153 | * @throws InvalidArgumentException
154 | * @throws InvalidMethodNameException
155 | * @throws OriginalConstructorInvocationRequiredException
156 | * @throws ReflectionException
157 | * @throws RuntimeException
158 | * @throws UnknownTypeException
159 | * @throws \PHPUnit\Framework\InvalidArgumentException
160 | * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException
161 | */
162 | public function hasGlobalScope(): void
163 | {
164 | $model = new class extends Model {
165 | public static ConnectionInterface|null $connection = null;
166 |
167 | public static function resolveConnection(
168 | string|null $connection = null,
169 | ): ConnectionInterface {
170 | assert(static::$connection !== null);
171 |
172 | return static::$connection;
173 | }
174 | };
175 | $model::$connection = $this->getConnection();
176 | $model::addGlobalScope('foo', static function (
177 | Query $query,
178 | ) {});
179 |
180 | self::assertTrue($model::hasGlobalScope('foo'));
181 | self::assertFalse($model::hasGlobalScope('bar'));
182 | }
183 |
184 | /**
185 | * @test
186 | * @throws ClassAlreadyExistsException
187 | * @throws ClassIsFinalException
188 | * @throws ClassIsReadonlyException
189 | * @throws DuplicateMethodException
190 | * @throws Exception
191 | * @throws ExpectationFailedException
192 | * @throws InvalidArgumentException
193 | * @throws InvalidMethodNameException
194 | * @throws OriginalConstructorInvocationRequiredException
195 | * @throws ReflectionException
196 | * @throws RuntimeException
197 | * @throws UnknownTypeException
198 | * @throws \PHPUnit\Framework\InvalidArgumentException
199 | * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException
200 | */
201 | public function withoutGlobalScope(): void
202 | {
203 | $model = new class extends Model {
204 | public static ConnectionInterface|null $connection = null;
205 |
206 | public static function resolveConnection(
207 | string|null $connection = null,
208 | ): ConnectionInterface {
209 | assert(static::$connection !== null);
210 |
211 | return static::$connection;
212 | }
213 | };
214 | $model::$connection = $this->getConnection();
215 |
216 | $scope = static function (Query $query): void {};
217 | $model::addGlobalScope('foo', $scope);
218 |
219 | self::assertTrue($model::hasGlobalScope('foo'));
220 | $query = $model->newQuery();
221 | self::assertNotContains('foo', $query->removedScopes());
222 | $query = $query->withoutGlobalScope('foo');
223 | self::assertContains('foo', $query->removedScopes());
224 | }
225 |
226 | /**
227 | * @test
228 | * @throws ClassAlreadyExistsException
229 | * @throws ClassIsFinalException
230 | * @throws ClassIsReadonlyException
231 | * @throws DuplicateMethodException
232 | * @throws Exception
233 | * @throws ExpectationFailedException
234 | * @throws InvalidArgumentException
235 | * @throws InvalidMethodNameException
236 | * @throws OriginalConstructorInvocationRequiredException
237 | * @throws ReflectionException
238 | * @throws RuntimeException
239 | * @throws UnknownTypeException
240 | * @throws \PHPUnit\Framework\InvalidArgumentException
241 | * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException
242 | */
243 | public function withoutGlobalScopes(): void
244 | {
245 | $model = new class extends Model {
246 | public static ConnectionInterface|null $connection = null;
247 |
248 | public static function resolveConnection(
249 | string|null $connection = null,
250 | ): ConnectionInterface {
251 | assert(static::$connection !== null);
252 |
253 | return static::$connection;
254 | }
255 | };
256 | $model::$connection = $this->getConnection();
257 |
258 | $foo = static function (Query $query): void {};
259 | $bar = static function (Query $query): void {};
260 | $model::addGlobalScope('foo', $foo);
261 | $model::addGlobalScope('bar', $bar);
262 |
263 | self::assertTrue($model::hasGlobalScope('foo'));
264 | $query = $model->newQuery();
265 | self::assertNotContains('foo', $query->removedScopes());
266 | self::assertNotContains('bar', $query->removedScopes());
267 | $query = $query->withoutGlobalScopes();
268 | self::assertContains('foo', $query->removedScopes());
269 | self::assertContains('bar', $query->removedScopes());
270 | }
271 |
272 | /**
273 | * @param string $name
274 | * @param mixed $value
275 | *
276 | * @return array
277 | * @throws InvalidArgumentException
278 | * @throws \PHPUnit\Framework\InvalidArgumentException
279 | * @throws ClassAlreadyExistsException
280 | * @throws ClassIsFinalException
281 | * @throws ClassIsReadonlyException
282 | * @throws DuplicateMethodException
283 | * @throws InvalidMethodNameException
284 | * @throws OriginalConstructorInvocationRequiredException
285 | * @throws ReflectionException
286 | * @throws RuntimeException
287 | * @throws UnknownTypeException
288 | */
289 | protected function getActual(string $name, mixed $value): array
290 | {
291 | $model = new class extends Model {
292 | public static ConnectionInterface|null $connection = null;
293 |
294 | public static function resolveConnection(
295 | string|null $connection = null,
296 | ): ConnectionInterface {
297 | assert(static::$connection !== null);
298 |
299 | return static::$connection;
300 | }
301 | };
302 | $model::$connection = $this->getConnection();
303 | $model::addGlobalScope('foo', fn(
304 | Query $query,
305 | ) => $query->where($name, $value));
306 |
307 | return $this->getQueryObject($model->newQuery())->toArray();
308 | }
309 |
310 | protected function getExpected(string $name, mixed $value): array
311 | {
312 | return $this->getQueryArray([
313 | 'query' => [
314 | 'bool' => [
315 | 'filter' => [
316 | [
317 | 'term' => [
318 | $name => $value,
319 | ],
320 | ],
321 | ],
322 | ],
323 | ],
324 | ]);
325 | }
326 | }
327 |
--------------------------------------------------------------------------------