Passed
Pull Request — main (#18)
by Paolo
01:28
created

SearchIndex::indexExists()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 0
dl 0
loc 5
rs 10
c 0
b 0
f 0
1
<?php
2
declare(strict_types=1);
3
4
namespace BEdita\ElasticSearch\Model\Index;
5
6
use Cake\Database\DriverInterface;
7
use Cake\Datasource\ConnectionManager;
8
use Cake\Datasource\EntityInterface;
9
use Cake\ElasticSearch\Index;
10
use Cake\ElasticSearch\Query;
11
use Cake\ElasticSearch\QueryBuilder;
12
use Cake\Log\Log;
13
use Cake\Log\LogTrait;
14
use Cake\Utility\Hash;
15
use Cake\Utility\Inflector;
16
use Elastica\Query\AbstractQuery;
17
use Elasticsearch\Endpoints\Indices\PutMapping;
18
use Elasticsearch\Endpoints\Indices\PutSettings;
19
20
/**
21
 * Base search index for ElasticSearch.
22
 */
23
class SearchIndex extends Index implements AdapterCompatibleInterface
24
{
25
    use IndexTrait;
26
    use LogTrait;
27
28
    /**
29
     * Map of fields and their configuration (type, analyzer, normalizer, etc.).
30
     *
31
     * This property must be overridden by implementations to customize the mappings used by the index.
32
     *
33
     * @var array
34
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-params.html
35
     */
36
    protected static array $_properties = [];
37
38
    /**
39
     * Settings for the text analysis (define/configure analyzers, tokenizers, filters, etc.).
40
     *
41
     * @var array
42
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/configure-text-analysis.html
43
     */
44
    protected static array $_analysis = [];
45
46
    /**
47
     * Returns the index name.
48
     *
49
     * If it isn't set, it is constructed from the default collection schema and the alias for the index.
50
     *
51
     * @return string
52
     */
53
    public function getName(): string
54
    {
55
        if ($this->_name === null) {
56
            $defaultName = $this->getDefaultName();
57
            if ($defaultName !== null) {
58
                $this->_name = $defaultName;
59
            }
60
        }
61
62
        return parent::getName();
63
    }
64
65
    /**
66
     * Returns the default index name, constructed from the default collection schema and the alias for the index.
67
     *
68
     * @return string|null
69
     */
70
    protected function getDefaultName(): ?string
71
    {
72
        $driver = ConnectionManager::get('default')->getDriver();
73
        if (!$driver instanceof DriverInterface) {
74
            return null;
75
        }
76
77
        $prefix = $driver->schema();
78
        $suffix = Inflector::underscore($this->getAlias());
79
        if (empty($prefix) || empty($suffix)) {
80
            return null;
81
        }
82
83
        return $prefix . '_' . $suffix;
84
    }
85
86
    /**
87
     * @inheritDoc
88
     */
89
    public function indexExists(): bool
90
    {
91
        return $this->getConnection()
92
            ->getIndex($this->getName())
93
            ->exists();
94
    }
95
96
    /**
97
     * @inheritDoc
98
     */
99
    public function create(array $arguments = [], array $options = []): bool
100
    {
101
        if (empty(Hash::get($arguments, 'mappings.properties')) && !empty(static::$_properties)) {
102
            $arguments = Hash::insert($arguments, 'mappings.properties', static::$_properties);
103
        }
104
        if (empty(Hash::get($arguments, 'settings.analysis')) && !empty(static::$_analysis)) {
105
            $arguments = Hash::insert($arguments, 'settings.analysis', static::$_analysis);
106
        }
107
108
        $esIndex = $this->getConnection()->getIndex($this->getName());
109
        $response = $esIndex->create($arguments, $options);
110
        if (!$response->isOk()) {
111
            Log::error(sprintf(
112
                'Error creating index "%s": %s',
113
                $this->getName(),
114
                $response->getErrorMessage(),
115
            ));
116
117
            return false;
118
        }
119
120
        return true;
121
    }
122
123
    /**
124
     * @inheritDoc
125
     */
126
    public function updateProperties(array $properties = []): bool
127
    {
128
        if (empty($properties)) {
129
            $properties = static::$_properties;
130
        }
131
132
        $endpoint = new PutMapping();
133
        $endpoint->setBody(compact('properties'));
134
        $esIndex = $this->getConnection()->getIndex($this->getName());
135
        $response = $esIndex->requestEndpoint($endpoint);
136
        if (!$response->isOk()) {
137
            Log::error(sprintf(
138
                'Error updating index "%s" mappings: %s',
139
                $this->getName(),
140
                $response->getErrorMessage(),
141
            ));
142
143
            return false;
144
        }
145
146
        return true;
147
    }
148
149
    /**
150
     * Temporarily close the index to perform operations that require it to be closed.
151
     *
152
     * @template T
153
     * @param callable(): T $callback Callback to execute while the index is closed.
154
     * @return T
155
     */
156
    protected function withClosedIndex(callable $callback): mixed
157
    {
158
        $esIndex = $this->getConnection()->getIndex($this->getName());
159
        $response = $esIndex->close();
160
        if (!$response->isOk()) {
161
            Log::error(sprintf(
162
                'Error closing the index "%s": %s',
163
                $this->getName(),
164
                $response->getErrorMessage(),
165
            ));
166
167
            throw new \RuntimeException('Unable to close the index');
168
        }
169
170
        try {
171
            return $callback();
172
        } finally {
173
            $response = $esIndex->open();
174
            if (!$response->isOk()) {
175
                Log::error(sprintf(
176
                    'Error opening the index "%s": %s',
177
                    $this->getName(),
178
                    $response->getErrorMessage(),
179
                ));
180
            }
181
        }
182
    }
183
184
    /**
185
     * @inheritDoc
186
     */
187
    public function updateAnalysis(array $analysis = []): bool
188
    {
189
        $esIndex = $this->getConnection()->getIndex($this->getName());
190
        if (empty($analysis)) {
191
            $analysis = static::$_analysis;
192
        }
193
194
        return $this->withClosedIndex(function () use ($analysis, $esIndex): bool {
195
            if (empty($analysis)) {
196
                Log::warning(sprintf(
197
                    'No analysis settings provided for index "%s", skipping update.',
198
                    $this->getName(),
199
                ));
200
201
                return true;
202
            }
203
204
            $endpoint = new PutSettings();
205
            $endpoint->setBody(compact('analysis'));
206
            $response = $esIndex->requestEndpoint($endpoint);
207
            if (!$response->isOk()) {
208
                Log::error(sprintf(
209
                    'Error updating index "%s" settings: %s',
210
                    $this->getName(),
211
                    $response->getErrorMessage(),
212
                ));
213
214
                return false;
215
            }
216
217
            return true;
218
        });
219
    }
220
221
    /**
222
     * Delete a document from the index knowing its ID, or throw an exception upon failure.
223
     *
224
     * @param string $id Document ID.
225
     * @param array $options Options to be passed to {@see \Cake\ElasticSearch\Index::delete()} method.
226
     * @return void
227
     */
228
    public function deleteByIdOrFail(string $id, array $options = []): void
229
    {
230
        $document = $this->getIfExists($id);
231
        if ($document === null) {
232
            return;
233
        }
234
235
        $this->deleteOrFail($document, $options);
236
    }
237
238
    /**
239
     * Prepare data for indexing. This method may be overridden by implementations to customize indexed fields.
240
     *
241
     * If `null` is returned, entity indexing is skipped (and an entity with such ID is removed
242
     * from index if already present).
243
     *
244
     * @param \Cake\Datasource\EntityInterface $entity Entity to be indexed.
245
     * @return array<string, mixed>|null
246
     */
247
    protected function prepareData(EntityInterface $entity): ?array
248
    {
249
        return ['id' => (string)$entity->id] + $entity->toArray();
250
    }
251
252
    /**
253
     * @inheritDoc
254
     */
255
    public function reindex(EntityInterface $entity, string $operation): void
256
    {
257
        $id = (string)$entity->id;
258
        switch ($operation) {
259
            case 'edit':
260
                $data = $this->prepareData($entity);
261
                if ($data === null) {
262
                    $this->deleteByIdOrFail($id);
263
                } else {
264
                    $document = $this->patchEntity($this->getIfExists($id) ?: $this->newEmptyEntity(), $data);
265
                    $this->saveOrFail($document);
266
                }
267
                break;
268
269
            case 'delete':
270
                $this->deleteByIdOrFail($id);
271
                break;
272
273
            default:
274
                Log::warning(sprintf('Unknown operation on ElasticSearch reindex: %s', $operation));
275
        }
276
    }
277
278
    /**
279
     * @inheritDoc
280
     */
281
    public function findQuery(Query $query, array $options): Query
282
    {
283
        return $query->queryMust(
284
            fn(QueryBuilder $builder): AbstractQuery => $builder->simpleQueryString('title', $options['query']),
285
        );
286
    }
287
}
288