ElasticSearchAdapter   A
last analyzed

Complexity

Total Complexity 14

Size/Duplication

Total Lines 173
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 72
dl 0
loc 173
rs 10
c 1
b 0
f 0
wmc 14

6 Methods

Rating   Name   Duplication   Size   Complexity  
A getIndex() 0 24 5
A buildQuery() 0 26 3
A buildElasticSearchQuery() 0 9 1
A createTempTable() 0 47 3
A search() 0 3 1
A indexResource() 0 3 1
1
<?php
2
declare(strict_types=1);
3
4
namespace BEdita\ElasticSearch\Adapter;
5
6
use BEdita\Core\Search\BaseAdapter;
7
use BEdita\ElasticSearch\Model\Document\Search;
8
use BEdita\ElasticSearch\Model\Index\AdapterCompatibleInterface;
9
use Cake\Database\Connection;
10
use Cake\Database\Expression\ComparisonExpression;
11
use Cake\Database\Expression\QueryExpression;
12
use Cake\Database\Schema\TableSchema;
13
use Cake\Datasource\EntityInterface;
14
use Cake\Datasource\FactoryLocator;
15
use Cake\ElasticSearch\Index;
16
use Cake\Log\LogTrait;
17
use Cake\ORM\Locator\LocatorAwareTrait;
18
use Cake\ORM\Query;
19
use Cake\ORM\Table;
20
use Cake\Utility\Security;
21
use Exception;
22
use Psr\Log\LogLevel;
23
use RuntimeException;
24
use UnexpectedValueException;
25
26
/**
27
 * ElasticSearch adapter for BEdita search.
28
 */
29
class ElasticSearchAdapter extends BaseAdapter
30
{
31
    use LocatorAwareTrait;
32
    use LogTrait;
33
34
    protected const MAX_RESULTS = 1000;
35
36
    /**
37
     * Index instance.
38
     *
39
     * @var \Cake\ElasticSearch\Index&\BEdita\ElasticSearch\Model\Index\AdapterCompatibleInterface
40
     */
41
    protected Index&AdapterCompatibleInterface $index;
42
43
    /**
44
     * Get index instance for search index.
45
     *
46
     * @return \BEdita\ElasticSearch\Model\Index\AdapterCompatibleInterface&\Cake\ElasticSearch\Index
47
     */
48
    public function getIndex(): Index&AdapterCompatibleInterface
49
    {
50
        if (!isset($this->index)) {
51
            $index = $this->getConfig('index', 'BEdita/ElasticSearch.Search');
52
            if (is_string($index)) {
53
                /** @var \Cake\ElasticSearch\Datasource\IndexLocator $locator */
54
                $locator = FactoryLocator::get('ElasticSearch');
55
                /** @var array<string, mixed> $options */
56
                $options = (array)$this->getConfig('options');
57
                $index = $locator->get($index, $options);
58
            }
59
            if (!$index instanceof Index || !$index instanceof AdapterCompatibleInterface) {
60
                throw new UnexpectedValueException(sprintf(
61
                    'Search index must be an instance of %s that implements %s interface, got %s',
62
                    Index::class,
63
                    AdapterCompatibleInterface::class,
64
                    get_debug_type($index),
65
                ));
66
            }
67
68
            $this->index = $index;
69
        }
70
71
        return $this->index;
72
    }
73
74
    /**
75
     * @inheritDoc
76
     */
77
    public function search(Query $query, string $text, array $options = []): Query
78
    {
79
        return $this->buildQuery($query, $text, $options);
80
    }
81
82
    /**
83
     * {@inheritDoc}
84
     *
85
     * @codeCoverageIgnore
86
     */
87
    public function indexResource(EntityInterface $entity, string $operation): void
88
    {
89
        $this->getIndex()->reindex($entity, $operation);
90
    }
91
92
    /**
93
     * Build elastic search query
94
     *
95
     * @param string $text The search text
96
     * @param array $options The options
97
     * @return array<array{id: string, score: float}>
98
     */
99
    protected function buildElasticSearchQuery(string $text, array $options): array
100
    {
101
        return $this->getIndex()
102
            ->find('query', ['query' => $text] + $options)
103
            ->select(['_id', '_score'])
104
            ->limit(static::MAX_RESULTS)
105
            ->all()
106
            ->map(fn (Search $doc): array => ['id' => $doc->id, 'score' => $doc->score()])
107
            ->toList();
108
    }
109
110
    /**
111
     * Build query and return it
112
     *
113
     * @param \Cake\ORM\Query $query The query
114
     * @param string $text The search text
115
     * @param array $options The options
116
     * @return \Cake\ORM\Query
117
     */
118
    protected function buildQuery(Query $query, string $text, array $options): Query
119
    {
120
        $results = $this->buildElasticSearchQuery($text, $options);
121
        if (count($results) === 0) {
122
            // Nothing found. No results should be returned. Add a contradiction to the `WHERE` clause.
123
            return $query->where(new ComparisonExpression('1', '1', 'integer', '<>'));
124
        }
125
126
        // Prepare temporary table with `id` and `score` from ElasticSearch results.
127
        $tempTable = $this->createTempTable($query->getConnection());
128
        $insertQuery = $tempTable->query()->insert(['id', 'score']);
129
        foreach ($results as $row) {
130
            $insertQuery = $insertQuery->values($row);
131
        }
132
        $insertQuery->execute();
133
134
        // Add a join with the temporary table to filter by ID and sort by relevance score.
135
        return $query
136
            ->innerJoin(
137
                $tempTable->getTable(),
138
                (new QueryExpression())->equalFields(
139
                    $tempTable->aliasField('id'),
140
                    $query->getRepository()->aliasField('id'),
141
                ),
142
            )
143
            ->orderDesc($tempTable->aliasField('score'));
144
    }
145
146
    /**
147
     * Create a temporary table to store search results.
148
     * The table is created with a `score` column to sort results by relevance.
149
     * The table is dropped when the connection is closed.
150
     *
151
     * @param \Cake\Database\Connection $connection The database connection
152
     * @return \Cake\ORM\Table
153
     * @throws \RuntimeException
154
     */
155
    protected function createTempTable(Connection $connection): Table
156
    {
157
        $table = sprintf('elasticsearch_%s', Security::randomString(16));
158
        $schema = (new TableSchema($table))
159
            ->setTemporary(true)
160
            ->addColumn('id', [
161
                'type' => TableSchema::TYPE_INTEGER,
162
                'length' => 11,
163
                'unsigned' => true,
164
                'null' => false,
165
            ])
166
            ->addColumn('score', [
167
                'type' => TableSchema::TYPE_FLOAT,
168
                'null' => false,
169
            ])
170
            ->addConstraint(
171
                'PRIMARY',
172
                [
173
                    'type' => TableSchema::CONSTRAINT_PRIMARY,
174
                    'columns' => ['id'],
175
                ]
176
            )
177
            ->addIndex(
178
                sprintf('%s_score_idx', str_replace('_', '', $table)),
179
                [
180
                    'type' => TableSchema::INDEX_INDEX,
181
                    'columns' => ['score'],
182
                ]
183
            );
184
185
        try {
186
            // Execute SQL to create table. In MySQL the transaction is completely useless,
187
            // because `CREATE TABLE` implicitly implies a commit.
188
            $connection->transactional(function (Connection $connection) use ($schema): void {
189
                foreach ($schema->createSql($connection) as $statement) {
190
                    $connection->execute($statement);
191
                }
192
            });
193
        } catch (Exception $e) {
194
            $this->log(sprintf('Could not create temporary table for ElasticSearch results: %s', $e), LogLevel::ERROR);
195
196
            throw new RuntimeException('Could not create temporary table for ElasticSearch results', 0, $e);
197
        }
198
199
        return (new Table(compact('connection', 'table', 'schema')))
200
            ->setPrimaryKey('id')
201
            ->setDisplayField('score');
202
    }
203
}
204