Passed
Push — main ( c9564a...7b74a7 )
by Dante
01:05
created

ElasticSearchAdapter::buildQuery()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 28
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 15
c 1
b 0
f 0
dl 0
loc 28
rs 9.7666
cc 3
nc 3
nop 3
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
                // @phpstan-ignore-next-line
85
                fn (QueryExpression $exp): QueryExpression => $exp->equalFields(
86
                    $tempTable->aliasField('id'),
87
                    $query->getRepository()->aliasField('id'),
88
                )
89
            )
90
            ->orderDesc($tempTable->aliasField('score'));
91
    }
92
93
    /**
94
     * Create a temporary table to store search results.
95
     * The table is created with a `score` column to sort results by relevance.
96
     * The table is dropped when the connection is closed.
97
     *
98
     * @param \Cake\Database\Connection $connection The database connection
99
     * @return \Cake\ORM\Table
100
     * @throws \RuntimeException
101
     */
102
    protected function createTempTable(Connection $connection): Table
103
    {
104
        $table = sprintf('elasticsearch_%s', Security::randomString(16));
105
        $schema = (new TableSchema($table))
106
            ->setTemporary(true)
107
            ->addColumn('id', [
108
                'type' => TableSchema::TYPE_INTEGER,
109
                'length' => 11,
110
                'unsigned' => true,
111
                'null' => false,
112
            ])
113
            ->addColumn('score', [
114
                'type' => TableSchema::TYPE_FLOAT,
115
                'null' => false,
116
            ])
117
            ->addConstraint(
118
                'PRIMARY',
119
                [
120
                    'type' => TableSchema::CONSTRAINT_PRIMARY,
121
                    'columns' => ['id'],
122
                ]
123
            )
124
            ->addIndex(
125
                sprintf('%s_score_idx', str_replace('_', '', $table)),
126
                [
127
                    'type' => TableSchema::INDEX_INDEX,
128
                    'columns' => ['score'],
129
                ]
130
            );
131
132
        try {
133
            // Execute SQL to create table. In MySQL the transaction is completely useless,
134
            // because `CREATE TABLE` implicitly implies a commit.
135
            $connection->transactional(function (Connection $connection) use ($schema): void {
136
                foreach ($schema->createSql($connection) as $statement) {
137
                    $connection->execute($statement);
138
                }
139
            });
140
        } catch (Exception $e) {
141
            $this->log(sprintf('Could not create temporary table for ElasticSearch results: %s', $e), LogLevel::ERROR);
142
143
            throw new RuntimeException('Could not create temporary table for ElasticSearch results', 0, $e);
144
        }
145
146
        return (new Table(compact('connection', 'table', 'schema')))
147
            ->setPrimaryKey('id')
148
            ->setDisplayField('score');
149
    }
150
}
151