Passed
Pull Request — main (#2)
by Dante
01:03
created

ElasticSearchAdapter::search()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 3
dl 0
loc 3
rs 10
1
<?php
2
declare(strict_types=1);
3
4
namespace BEdita\ElasticSearch\Adapter;
5
6
use BEdita\Core\Search\BaseAdapter;
7
use Cake\Database\Connection;
8
use Cake\Database\Expression\ComparisonExpression;
9
use Cake\Database\Expression\QueryExpression;
10
use Cake\Database\Schema\TableSchema;
11
use Cake\Datasource\EntityInterface;
12
use Cake\Log\LogTrait;
13
use Cake\ORM\Locator\LocatorAwareTrait;
14
use Cake\ORM\Query;
15
use Cake\ORM\Table;
16
use Cake\Utility\Security;
17
use Exception;
18
use Psr\Log\LogLevel;
19
use RuntimeException;
20
21
class ElasticSearchAdapter extends BaseAdapter
22
{
23
    use LocatorAwareTrait;
24
    use LogTrait;
25
26
    /**
27
     * @inheritDoc
28
     */
29
    public function search(Query $query, string $text, array $options = []): Query
30
    {
31
        return $this->buildQuery($query, $text, $options);
32
    }
33
34
    /**
35
     * {@inheritDoc}
36
     *
37
     * @codeCoverageIgnore
38
     */
39
    public function indexResource(EntityInterface $entity, string $operation): void
40
    {
41
    }
42
43
    /**
44
     * Build elastic search query
45
     *
46
     * @param string $text The search text
47
     * @param array $options The options
48
     * @return array
49
     */
50
    protected function buildElasticSearchQuery(string $text, array $options): array
51
    {
52
        return [];
53
    }
54
55
    /**
56
     * Build query and return it
57
     *
58
     * @param \Cake\ORM\Query $query The query
59
     * @param string $text The search text
60
     * @param array $options The options
61
     * @return \Cake\ORM\Query
62
     */
63
    protected function buildQuery(Query $query, string $text, array $options): Query
64
    {
65
        $results = $this->buildElasticSearchQuery($text, $options);
66
67
        if (count($results) === 0) {
68
            // Nothing found. No results should be returned. Add a contradiction to the `WHERE` clause.
69
            return $query->where(new ComparisonExpression('1', '1', 'integer', '<>'));
70
        }
71
72
        // Prepare temporary table with `id` and `score` from ElasticSearch results.
73
        $tempTable = $this->createTempTable($query->getConnection());
74
        $insertQuery = $tempTable->query()->insert(['id', 'score']);
75
        foreach ($results as $row) {
76
            $insertQuery = $insertQuery->values($row);
77
        }
78
        $insertQuery->execute();
79
80
        // Add a join with the temporary table to filter by ID and sort by relevance score.
81
        return $query
82
            ->innerJoin(
83
                $tempTable->getTable(),
84
                fn (QueryExpression $exp): QueryExpression => $exp->equalFields(
85
                    $tempTable->aliasField('id'),
86
                    $query->getRepository()->aliasField('id'),
87
                )
88
            )
89
            ->orderDesc($tempTable->aliasField('score'));
90
    }
91
92
    /**
93
     * Create a temporary table to store search results.
94
     * The table is created with a `score` column to sort results by relevance.
95
     * The table is dropped when the connection is closed.
96
     *
97
     * @param \Cake\Database\Connection $connection The database connection
98
     * @return \Cake\ORM\Table
99
     * @throws \RuntimeException
100
     */
101
    protected function createTempTable(Connection $connection): Table
102
    {
103
        $table = sprintf('elasticsearch_%s', Security::randomString(16));
104
        $schema = (new TableSchema($table))
105
            ->setTemporary(true)
106
            ->addColumn('id', [
107
                'type' => TableSchema::TYPE_INTEGER,
108
                'length' => 11,
109
                'unsigned' => true,
110
                'null' => false,
111
            ])
112
            ->addColumn('score', [
113
                'type' => TableSchema::TYPE_FLOAT,
114
                'null' => false,
115
            ])
116
            ->addConstraint(
117
                'PRIMARY',
118
                [
119
                    'type' => TableSchema::CONSTRAINT_PRIMARY,
120
                    'columns' => ['id'],
121
                ]
122
            )
123
            ->addIndex(
124
                sprintf('%s_score_idx', str_replace('_', '', $table)),
125
                [
126
                    'type' => TableSchema::INDEX_INDEX,
127
                    'columns' => ['score'],
128
                ]
129
            );
130
131
        try {
132
            // Execute SQL to create table. In MySQL the transaction is completely useless,
133
            // because `CREATE TABLE` implicitly implies a commit.
134
            $connection->transactional(function (Connection $connection) use ($schema): void {
135
                foreach ($schema->createSql($connection) as $statement) {
136
                    $connection->execute($statement);
137
                }
138
            });
139
        } catch (Exception $e) {
140
            $this->log(sprintf('Could not create temporary table for ElasticSearch results: %s', $e), LogLevel::ERROR);
141
142
            throw new RuntimeException('Could not create temporary table for ElasticSearch results', $e);
143
        }
144
145
        return (new Table(compact('connection', 'table', 'schema')))
146
            ->setPrimaryKey('id')
147
            ->setDisplayField('score');
148
    }
149
}
150