Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.70% covered (success)
88.70%
259 / 292
45.45% covered (danger)
45.45%
10 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Elasticsearch
88.70% covered (success)
88.70%
259 / 292
45.45% covered (danger)
45.45%
10 / 22
69.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 createIndex
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getParams
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
1
 buildMappings
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
1
 getSearchAnalyzer
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
7.46
 getTokenizer
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getNumberOfShards
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getNumberOfReplicas
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getStemmingLanguage
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
10.75
 putMapping
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 getMapping
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
 unwrapResponse
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 dropIndex
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 index
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 bulkIndex
82.35% covered (success)
82.35%
28 / 34
0.00% covered (danger)
0.00%
0 / 1
7.27
 update
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 delete
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 isAvailable
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
3.69
 indexCustomPage
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 updateCustomPage
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
5
 deleteCustomPage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 bulkIndexCustomPages
86.49% covered (success)
86.49%
32 / 37
0.00% covered (danger)
0.00%
0 / 1
9.20
1<?php
2
3/**
4 * The phpMyFAQ instances a basic Elasticsearch class.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2015-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2015-12-25
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Instance\Search;
21
22use Elastic\Elasticsearch\Client;
23use Elastic\Elasticsearch\Exception\ClientResponseException;
24use Elastic\Elasticsearch\Exception\MissingParameterException;
25use Elastic\Elasticsearch\Exception\ServerResponseException;
26use Elastic\Elasticsearch\Response\Elasticsearch as ElasticsearchResponse;
27use Http\Promise\Promise;
28use phpMyFAQ\Configuration;
29use phpMyFAQ\Configuration\ElasticsearchConfiguration;
30use phpMyFAQ\Core\Exception;
31
32/**
33 * Class Elasticsearch
34 *
35 * @package phpMyFAQ\Instance
36 */
37class Elasticsearch
38{
39    protected Client $client;
40
41    protected ElasticsearchConfiguration $elasticsearchConfiguration;
42
43    /**
44     * Elasticsearch mapping
45     * @var array<string, mixed>
46     */
47    private array $mappings = [];
48
49    /**
50     * Elasticsearch constructor.
51     */
52    public function __construct(
53        protected Configuration $configuration,
54    ) {
55        $this->client = $configuration->getElasticsearch();
56        $this->elasticsearchConfiguration = $configuration->getElasticsearchConfig();
57        $this->mappings = $this->buildMappings();
58    }
59
60    /**
61     * Creates the Elasticsearch index.
62     *
63     * @throws Exception
64     */
65    public function createIndex(): bool
66    {
67        try {
68            $this->client->indices()->create($this->getParams());
69            return $this->putMapping();
70        } catch (ClientResponseException|MissingParameterException|ServerResponseException $e) {
71            throw new Exception($e->getMessage());
72        }
73    }
74
75    /**
76     * Returns the basic phpMyFAQ index structure as raw array.
77     *
78     * @return array{index: string, body: array<string, mixed>}
79     */
80    private function getParams(): array
81    {
82        $tokenizer = $this->getTokenizer();
83
84        return [
85            'index' => $this->elasticsearchConfiguration->getIndex(),
86            'body' => [
87                'settings' => [
88                    'number_of_shards' => $this->getNumberOfShards(),
89                    'number_of_replicas' => $this->getNumberOfReplicas(),
90                    'analysis' => [
91                        'filter' => [
92                            'autocomplete_filter' => [
93                                'type' => 'edge_ngram',
94                                'min_gram' => 1,
95                                'max_gram' => 20,
96                            ],
97                            'Language_stemmer' => [
98                                'type' => 'stemmer',
99                                'name' => $this->getStemmingLanguage(),
100                            ],
101                        ],
102                        'analyzer' => [
103                            'autocomplete' => [
104                                'type' => 'custom',
105                                'tokenizer' => $tokenizer,
106                                'filter' => [
107                                    'lowercase',
108                                    'autocomplete_filter',
109                                    'Language_stemmer',
110                                ],
111                            ],
112                        ],
113                    ],
114                ],
115            ],
116        ];
117    }
118
119    /**
120     * @return array<string, mixed>
121     */
122    private function buildMappings(): array
123    {
124        $searchAnalyzer = $this->getSearchAnalyzer();
125
126        return [
127            '_source' => [
128                'enabled' => true,
129            ],
130            'properties' => [
131                'question' => [
132                    'type' => 'search_as_you_type',
133                    'analyzer' => 'autocomplete',
134                    'search_analyzer' => $searchAnalyzer,
135                ],
136                'answer' => [
137                    'type' => 'search_as_you_type',
138                    'analyzer' => 'autocomplete',
139                    'search_analyzer' => $searchAnalyzer,
140                ],
141                'keywords' => [
142                    'type' => 'search_as_you_type',
143                    'analyzer' => 'autocomplete',
144                    'search_analyzer' => $searchAnalyzer,
145                ],
146                'categories' => [
147                    'type' => 'search_as_you_type',
148                    'analyzer' => 'autocomplete',
149                    'search_analyzer' => $searchAnalyzer,
150                ],
151                'content_type' => [
152                    'type' => 'keyword',
153                ],
154                'slug' => [
155                    'type' => 'keyword',
156                ],
157            ],
158        ];
159    }
160
161    private function getSearchAnalyzer(): string
162    {
163        if (defined('PMF_ELASTICSEARCH_SEARCH_ANALYZER')) {
164            $searchAnalyzer = constant('PMF_ELASTICSEARCH_SEARCH_ANALYZER');
165            if (is_string($searchAnalyzer) && $searchAnalyzer !== '') {
166                return $searchAnalyzer;
167            }
168        }
169
170        return 'standard';
171    }
172
173    private function getTokenizer(): string
174    {
175        if (defined('PMF_ELASTICSEARCH_TOKENIZER')) {
176            return (string) constant('PMF_ELASTICSEARCH_TOKENIZER');
177        }
178
179        return 'standard';
180    }
181
182    private function getNumberOfShards(): int
183    {
184        if (defined('PMF_ELASTICSEARCH_NUMBER_SHARDS')) {
185            return (int) constant('PMF_ELASTICSEARCH_NUMBER_SHARDS');
186        }
187
188        return 2;
189    }
190
191    private function getNumberOfReplicas(): int
192    {
193        if (defined('PMF_ELASTICSEARCH_NUMBER_REPLICAS')) {
194            return (int) constant('PMF_ELASTICSEARCH_NUMBER_REPLICAS');
195        }
196
197        return 0;
198    }
199
200    private function getStemmingLanguage(): string
201    {
202        if (!defined('PMF_ELASTICSEARCH_STEMMING_LANGUAGE')) {
203            return 'english';
204        }
205
206        $defaultLanguage = $this->configuration->getDefaultLanguage();
207        $stemmingLanguages = constant('PMF_ELASTICSEARCH_STEMMING_LANGUAGE');
208
209        if (!is_array($stemmingLanguages)) {
210            return 'english';
211        }
212
213        $stemmer = $stemmingLanguages[$defaultLanguage] ?? 'english';
214
215        return is_string($stemmer) ? $stemmer : 'english';
216    }
217
218    /**
219     * Puts phpMyFAQ Elasticsearch mapping into index.
220     */
221    public function putMapping(): bool
222    {
223        $response = $this->getMapping();
224        $indexMapping = $response instanceof ElasticsearchResponse
225            ? $response[$this->elasticsearchConfiguration->getIndex()]
226            : null;
227        $currentMappings = is_array($indexMapping) ? $indexMapping['mappings'] ?? null : null;
228
229        if (!is_array($currentMappings) || $currentMappings === []) {
230            $params = [
231                'index' => $this->elasticsearchConfiguration->getIndex(),
232                'body' => $this->mappings,
233            ];
234
235            $this->client->indices()->putMapping($params);
236        }
237
238        return true;
239    }
240
241    /**
242     * Returns the current mapping.
243     *
244     * @throws Exception
245     */
246    public function getMapping(): \Elastic\Elasticsearch\Response\Elasticsearch|Promise
247    {
248        try {
249            return $this->client->indices()->getMapping();
250        } catch (ClientResponseException|ServerResponseException $e) {
251            throw new Exception($e->getMessage());
252        }
253    }
254
255    /**
256     * Unwraps a synchronous Elasticsearch response. The client is configured
257     * for synchronous requests, so receiving a Promise is a programming error.
258     */
259    private function unwrapResponse(ElasticsearchResponse|Promise $response): ElasticsearchResponse
260    {
261        if (!$response instanceof ElasticsearchResponse) {
262            throw new \RuntimeException('Unexpected asynchronous Elasticsearch response.');
263        }
264
265        return $response;
266    }
267
268    /**
269     * Deletes the Elasticsearch index.
270     *
271     * @throws Exception
272     */
273    public function dropIndex(): object
274    {
275        try {
276            return $this->unwrapResponse($this->client
277                ->indices()
278                ->delete([
279                    'index' => $this->elasticsearchConfiguration->getIndex(),
280                ]))->asObject();
281        } catch (ClientResponseException|MissingParameterException|ServerResponseException $exception) {
282            throw new Exception($exception->getMessage());
283        }
284    }
285
286    /**
287     * Indexing of a FAQ
288     *
289     * @param array<string, int|string|null> $faq
290     */
291    public function index(array $faq): ?object
292    {
293        $params = [
294            'index' => $this->elasticsearchConfiguration->getIndex(),
295            'id' => (string) ($faq['solution_id'] ?? ''),
296            'body' => [
297                'id' => (int) ($faq['id'] ?? 0),
298                'lang' => (string) ($faq['lang'] ?? ''),
299                'question' => (string) ($faq['question'] ?? ''),
300                'answer' => strip_tags((string) ($faq['answer'] ?? '')),
301                'keywords' => (string) ($faq['keywords'] ?? ''),
302                'category_id' => (int) ($faq['category_id'] ?? 0),
303                'content_type' => 'faq',
304            ],
305        ];
306
307        try {
308            return $this->unwrapResponse($this->client->index($params))->asObject();
309        } catch (ClientResponseException|MissingParameterException|ServerResponseException $e) {
310            $this->configuration->getLogger()->error('Index error.', [$e->getMessage()]);
311            return null;
312        }
313    }
314
315    /**
316     * Bulk indexing of all FAQs
317     *
318     * @param array<int, array<string, mixed>> $faqs
319     * @return array<string, mixed>
320     */
321    public function bulkIndex(array $faqs): array
322    {
323        $params = ['body' => []];
324        $responses = [];
325        $i = 1;
326
327        foreach ($faqs as $faq) {
328            if ('no' === ($faq['active'] ?? 'no')) {
329                continue;
330            }
331
332            $params['body'][] = [
333                'index' => [
334                    '_index' => $this->elasticsearchConfiguration->getIndex(),
335                    '_id' => (string) ($faq['solution_id'] ?? ''),
336                ],
337            ];
338
339            $params['body'][] = [
340                'id' => (int) ($faq['id'] ?? 0),
341                'lang' => (string) ($faq['lang'] ?? ''),
342                'question' => (string) ($faq['title'] ?? ''),
343                'answer' => strip_tags((string) ($faq['content'] ?? '')),
344                'keywords' => (string) ($faq['keywords'] ?? ''),
345                'category_id' => (int) ($faq['category_id'] ?? 0),
346                'content_type' => 'faq',
347            ];
348
349            if (($i % 1000) === 0) {
350                try {
351                    $responses = $this->client->bulk($params);
352                } catch (ClientResponseException|ServerResponseException $e) {
353                    return ['error' => $e->getMessage()];
354                }
355
356                $params = ['body' => []];
357                unset($responses);
358            }
359
360            ++$i;
361        }
362
363        // Send the last batch if it exists
364        try {
365            $responses = $this->unwrapResponse($this->client->bulk($params));
366        } catch (ClientResponseException|ServerResponseException $e) {
367            return ['error' => $e->getMessage()];
368        }
369
370        if ($responses->getStatusCode() === 200) {
371            return ['success' => $responses];
372        }
373
374        return ['error' => 'Unknown error.'];
375    }
376
377    /**
378     * Updates a FAQ document
379     *
380     * @param array<string, int|string|null> $faq
381     * @return array<array-key, mixed>
382     */
383    public function update(array $faq): array
384    {
385        $params = [
386            'index' => $this->elasticsearchConfiguration->getIndex(),
387            'id' => (string) ($faq['solution_id'] ?? ''),
388            'body' => [
389                'doc' => [
390                    'id' => (int) ($faq['id'] ?? 0),
391                    'lang' => (string) ($faq['lang'] ?? ''),
392                    'question' => (string) ($faq['question'] ?? ''),
393                    'answer' => strip_tags((string) ($faq['answer'] ?? '')),
394                    'keywords' => (string) ($faq['keywords'] ?? ''),
395                    'category_id' => (int) ($faq['category_id'] ?? 0),
396                    'content_type' => 'faq',
397                ],
398            ],
399        ];
400
401        try {
402            return $this->unwrapResponse($this->client->update($params))->asArray();
403        } catch (ClientResponseException|MissingParameterException|ServerResponseException $e) {
404            return ['error' => $e->getMessage()];
405        }
406    }
407
408    /**
409     * Deletes a FAQ document
410     *
411     * @return array<array-key, mixed>
412     */
413    public function delete(int $solutionId): array
414    {
415        $params = [
416            'index' => $this->elasticsearchConfiguration->getIndex(),
417            'id' => (string) $solutionId,
418        ];
419
420        try {
421            return $this->unwrapResponse($this->client->delete($params))->asArray();
422        } catch (ClientResponseException|MissingParameterException|ServerResponseException $e) {
423            return ['error' => $e->getMessage()];
424        }
425    }
426
427    /**
428     * Checks if Elasticsearch is available
429     */
430    public function isAvailable(): bool
431    {
432        try {
433            return $this->unwrapResponse($this->client->ping())->asBool();
434        } catch (ClientResponseException|ServerResponseException $e) {
435            $this->configuration->getLogger()->error('Elasticsearch ping failed.', [$e->getMessage()]);
436            return false;
437        }
438    }
439
440    /**
441     * Indexing of a custom page
442     *
443     * @param array<string, mixed> $page
444     */
445    public function indexCustomPage(array $page): ?object
446    {
447        // Only index active pages
448        if (($page['active'] ?? null) === 'n') {
449            // Delete from index if it exists (in case it was previously active)
450            $this->deleteCustomPage((int) ($page['id'] ?? 0), (string) ($page['lang'] ?? ''));
451            return null;
452        }
453
454        $params = [
455            'index' => $this->elasticsearchConfiguration->getIndex(),
456            'id' => 'page_' . (string) ($page['id'] ?? '') . '_' . (string) ($page['lang'] ?? ''),
457            'body' => [
458                'id' => (int) ($page['id'] ?? 0),
459                'lang' => (string) ($page['lang'] ?? ''),
460                'question' => (string) ($page['page_title'] ?? ''),
461                'answer' => strip_tags((string) ($page['content'] ?? '')),
462                'keywords' => '',
463                'category_id' => 0,
464                'content_type' => 'page',
465                'slug' => (string) ($page['slug'] ?? ''),
466            ],
467        ];
468
469        try {
470            return $this->unwrapResponse($this->client->index($params))->asObject();
471        } catch (ClientResponseException|MissingParameterException|ServerResponseException $e) {
472            $this->configuration->getLogger()->error('Index custom page error.', [$e->getMessage()]);
473            return null;
474        }
475    }
476
477    /**
478     * Updates a custom page document
479     *
480     * @param array<string, mixed> $page
481     * @return array<array-key, mixed>
482     */
483    public function updateCustomPage(array $page): array
484    {
485        // Only index active pages - delete from index if inactive
486        if (($page['active'] ?? null) === 'n') {
487            return $this->deleteCustomPage((int) ($page['id'] ?? 0), (string) ($page['lang'] ?? ''));
488        }
489
490        $params = [
491            'index' => $this->elasticsearchConfiguration->getIndex(),
492            'id' => 'page_' . (string) ($page['id'] ?? '') . '_' . (string) ($page['lang'] ?? ''),
493            'body' => [
494                'doc' => [
495                    'id' => (int) ($page['id'] ?? 0),
496                    'lang' => (string) ($page['lang'] ?? ''),
497                    'question' => (string) ($page['page_title'] ?? ''),
498                    'answer' => strip_tags((string) ($page['content'] ?? '')),
499                    'keywords' => '',
500                    'category_id' => 0,
501                    'content_type' => 'page',
502                    'slug' => (string) ($page['slug'] ?? ''),
503                ],
504            ],
505        ];
506
507        try {
508            return $this->unwrapResponse($this->client->update($params))->asArray();
509        } catch (ClientResponseException|MissingParameterException|ServerResponseException $e) {
510            // If document doesn't exist, try to create it
511            if (str_contains($e->getMessage(), 'document_missing_exception')) {
512                $result = $this->indexCustomPage($page);
513                return $result ? ['success' => true] : ['error' => 'Failed to create document'];
514            }
515            $this->configuration->getLogger()->error('Update custom page error.', [$e->getMessage()]);
516            return ['error' => $e->getMessage()];
517        }
518    }
519
520    /**
521     * Deletes a custom page document
522     *
523     * @return array<array-key, mixed>
524     */
525    public function deleteCustomPage(int $pageId, string $lang): array
526    {
527        $params = [
528            'index' => $this->elasticsearchConfiguration->getIndex(),
529            'id' => 'page_' . $pageId . '_' . $lang,
530        ];
531
532        try {
533            return $this->unwrapResponse($this->client->delete($params))->asArray();
534        } catch (ClientResponseException|MissingParameterException|ServerResponseException $e) {
535            return ['error' => $e->getMessage()];
536        }
537    }
538
539    /**
540     * Bulk indexing of custom pages
541     *
542     * @param array<int, array<string, mixed>> $pages
543     * @return array<string, mixed>
544     */
545    public function bulkIndexCustomPages(array $pages): array
546    {
547        $params = ['body' => []];
548        $responses = [];
549        $i = 1;
550
551        foreach ($pages as $page) {
552            if ('n' === ($page['active'] ?? 'n')) {
553                continue;
554            }
555
556            $params['body'][] = [
557                'index' => [
558                    '_index' => $this->elasticsearchConfiguration->getIndex(),
559                    '_id' => 'page_' . (string) ($page['id'] ?? '') . '_' . (string) ($page['lang'] ?? ''),
560                ],
561            ];
562
563            $params['body'][] = [
564                'id' => (int) ($page['id'] ?? 0),
565                'lang' => (string) ($page['lang'] ?? ''),
566                'question' => (string) ($page['page_title'] ?? ''),
567                'answer' => strip_tags((string) ($page['content'] ?? '')),
568                'keywords' => '',
569                'category_id' => 0,
570                'content_type' => 'page',
571                'slug' => (string) ($page['slug'] ?? ''),
572            ];
573
574            if (($i % 1000) === 0) {
575                try {
576                    $responses = $this->client->bulk($params);
577                } catch (ClientResponseException|ServerResponseException $e) {
578                    return ['error' => $e->getMessage()];
579                }
580
581                $params = ['body' => []];
582                unset($responses);
583            }
584
585            ++$i;
586        }
587
588        // Send the last batch if it exists
589        $responses = null;
590        if (($params['body'] ?? []) !== []) {
591            try {
592                $responses = $this->unwrapResponse($this->client->bulk($params));
593            } catch (ClientResponseException|ServerResponseException $e) {
594                return ['error' => $e->getMessage()];
595            }
596        }
597
598        if ($responses instanceof ElasticsearchResponse && $responses->getStatusCode() === 200) {
599            return ['success' => $responses];
600        }
601
602        return ['success' => true];
603    }
604}