SearchIndex::updateAnalysis()   A
last analyzed

Complexity

Conditions 4
Paths 2

Size

Total Lines 31
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

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