Completed
Push — main ( 978df3...dba4a1 )
by Paolo
22s queued 13s
created

ObjectSearchIndex::prepareData()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 2
eloc 3
c 1
b 1
f 0
nc 2
nop 1
dl 0
loc 7
rs 10
1
<?php
2
declare(strict_types=1);
3
4
namespace BEdita\ElasticSearch\Model\Index;
5
6
use BEdita\Core\Model\Entity\ObjectEntity;
7
use Cake\Core\Configure;
8
use Cake\Datasource\EntityInterface;
9
use Cake\ElasticSearch\Query;
10
use Cake\ElasticSearch\QueryBuilder;
11
use Cake\I18n\FrozenTime;
12
use Cake\Log\Log;
13
use Cake\ORM\Exception\PersistenceFailedException;
14
use Cake\Validation\Validator;
15
use Elastica\Exception\ResponseException;
16
use Elastica\Query\AbstractQuery;
17
use InvalidArgumentException;
18
19
/**
20
 * Base search index for BEdita objects in ElasticSearch.
21
 */
22
class ObjectSearchIndex extends SearchIndex
23
{
24
    /**
25
     * @inheritDoc
26
     */
27
    protected static array $_properties = [
28
        'uname' => ['type' => 'text'],
29
        'type' => ['type' => 'text'],
30
        'status' => ['type' => 'text'],
31
        'deleted' => ['type' => 'boolean'],
32
        'publish_start' => ['type' => 'date'],
33
        'publish_end' => ['type' => 'date'],
34
        'title' => ['type' => 'text'],
35
        'description' => ['type' => 'text'],
36
        'body' => ['type' => 'text'],
37
    ];
38
39
    /**
40
     * {@inheritDoc}
41
     *
42
     * @codeCoverageIgnore
43
     */
44
    public function validationDefault(Validator $validator): Validator
45
    {
46
        return $validator
47
            ->notEmptyString('uname')
48
            ->requirePresence('uname', 'create')
49
50
            ->notEmptyString('type')
51
            ->requirePresence('type', 'create')
52
53
            ->inList('status', ['on', 'draft', 'off'])
54
            ->requirePresence('status', 'create')
55
56
            ->boolean('deleted')
57
58
            ->dateTime('publish_start')
59
            ->allowEmptyDateTime('publish_start')
60
61
            ->dateTime('publish_end')
62
            ->allowEmptyDateTime('publish_end')
63
64
            ->scalar('title')
65
            ->allowEmptyString('title')
66
67
            ->scalar('description')
68
            ->allowEmptyString('description')
69
70
            ->scalar('body')
71
            ->allowEmptyString('body');
72
    }
73
74
    /**
75
     * @inheritDoc
76
     */
77
    public function reindex(EntityInterface $entity, string $operation): void
78
    {
79
        if (!$entity instanceof ObjectEntity) {
80
            Log::warning(sprintf(
81
                '%s index is supposed to be used only with sub-types of "%s", got "%s" instead',
82
                static::class,
83
                ObjectEntity::class,
84
                get_debug_type($entity),
85
            ));
86
            parent::reindex($entity, $operation);
87
88
            return;
89
        }
90
91
        switch ($operation) {
92
            case 'softDelete':
93
            case 'softDeleteRestore':
94
                try {
95
                    $id = (string)$entity->id;
96
                    if (!$this->set($id, 'deleted', $entity->deleted)) {
97
                        throw new PersistenceFailedException($this->get($id), ['set']);
98
                    }
99
                } catch (ResponseException $e) {
100
                    $fullError = (array)$e->getResponse()->getFullError();
101
                    if ($fullError['type'] !== 'document_missing_exception') {
102
                        throw $e;
103
                    }
104
105
                    // This scenario might be caused by an object that was not yet added to the index.
106
                    // Rather than updating a single field on an existing document, we should add the document instead.
107
                    parent::reindex($entity, 'edit');
108
                }
109
                break;
110
111
            default:
112
                parent::reindex($entity, $operation);
113
        }
114
    }
115
116
    /**
117
     * Prepare data for indexing. This method may be overridden by implementations to customize indexed fields.
118
     *
119
     * If `null` is returned, entity indexing is skipped (and an entity with such ID is removed
120
     * from index if already present).
121
     *
122
     * @param \Cake\Datasource\EntityInterface $entity Entity to be indexed.
123
     * @return array<string, mixed>|null
124
     */
125
    protected function prepareData(EntityInterface $entity): array|null
126
    {
127
        if (!$entity instanceof ObjectEntity) {
128
            return null;
129
        }
130
131
        return ['id' => (string)$entity->id] + $entity->extract(array_keys(static::$_properties));
132
    }
133
134
    /**
135
     * {@inheritDoc}
136
     *
137
     * @param \Cake\ElasticSearch\Query $query Query object instance.
138
     * @param array{query: string, type?: string|string[]} $options Search options.
139
     * @return \Cake\ElasticSearch\Query
140
     */
141
    public function findQuery(Query $query, array $options): Query
142
    {
143
        if (isset($options['type'])) {
144
            $query = $query->find('type', ['type' => $options['type']]);
145
        }
146
147
        return $query
148
            ->find('available')
149
            ->queryMust(
150
                fn (QueryBuilder $builder): AbstractQuery => $builder
151
                    ->simpleQueryString(['title', 'description', 'body'], $options['query']),
152
            );
153
    }
154
155
    /**
156
     * Find "available" documents, i.e. respective of deletion, status and publication constraints.
157
     *
158
     * @param \Cake\ElasticSearch\Query $query Query object instance.
159
     * @return \Cake\ElasticSearch\Query
160
     */
161
    protected function findAvailable(Query $query): Query
162
    {
163
        return $query->andWhere(function (QueryBuilder $builder): AbstractQuery {
164
            $conditions = [
165
                // Filter objects that are not deleted.
166
                $builder->term('deleted', 'false'),
167
            ];
168
169
            // Filter by object status.
170
            $statusLevel = Configure::read('Status.level', 'all');
171
            if ($statusLevel === 'on') {
172
                $conditions[] = $builder->term('status', 'on');
173
            } elseif ($statusLevel === 'draft') {
174
                $conditions[] = $builder->terms('status', ['on', 'draft']);
175
            }
176
177
            // Filter by publication date.
178
            if ((bool)Configure::read('Publish.checkDate', false)) {
179
                $now = FrozenTime::now();
180
181
                $conditions[] = $builder
182
                    ->or($builder->not($builder->exists('publish_start')), $builder->lte('publish_start', $now))
183
                    ->setMinimumShouldMatch(1);
184
                $conditions[] = $builder
185
                    ->or($builder->not($builder->exists('publish_end')), $builder->gte('publish_end', $now))
186
                    ->setMinimumShouldMatch(1);
187
            }
188
189
            return $builder->and(...$conditions);
190
        });
191
    }
192
193
    /**
194
     * Filter by object type.
195
     *
196
     * @param \Cake\ElasticSearch\Query $query Query object instance.
197
     * @param array{type: string|string[]} $options Finder options.
198
     * @return \Cake\ElasticSearch\Query
199
     */
200
    protected function findType(Query $query, array $options): Query
201
    {
202
        if (empty($options['type'])) {
203
            throw new InvalidArgumentException('Missing or empty `type` option');
204
        }
205
206
        return $query->andWhere(
207
            fn (QueryBuilder $builder): AbstractQuery => $builder->terms('type', (array)$options['type']),
208
        );
209
    }
210
}
211