ObjectSearchIndex::prepareData()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 2 Features 0
Metric Value
cc 2
eloc 6
c 2
b 2
f 0
nc 2
nop 1
dl 0
loc 10
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 [
132
            'id' => (string)$entity->id,
133
            'deleted' => $entity->deleted ?? false,
134
        ] + $entity->extract(array_keys(static::$_properties));
135
    }
136
137
    /**
138
     * {@inheritDoc}
139
     *
140
     * @param \Cake\ElasticSearch\Query $query Query object instance.
141
     * @param array{query: string, type?: string|string[]} $options Search options.
142
     * @return \Cake\ElasticSearch\Query
143
     */
144
    public function findQuery(Query $query, array $options): Query
145
    {
146
        if (isset($options['type'])) {
147
            $query = $query->find('type', ['type' => $options['type']]);
148
        }
149
150
        return $query
151
            ->find('available')
152
            ->queryMust(
153
                fn (QueryBuilder $builder): AbstractQuery => $builder
154
                    ->simpleQueryString(['title', 'description', 'body'], $options['query']),
155
            );
156
    }
157
158
    /**
159
     * Find "available" documents, i.e. respective of deletion, status and publication constraints.
160
     *
161
     * @param \Cake\ElasticSearch\Query $query Query object instance.
162
     * @return \Cake\ElasticSearch\Query
163
     */
164
    protected function findAvailable(Query $query): Query
165
    {
166
        return $query->andWhere(function (QueryBuilder $builder): AbstractQuery {
167
            $conditions = [
168
                // Filter objects that are not deleted.
169
                $builder->term('deleted', 'false'),
170
            ];
171
172
            // Filter by object status.
173
            $statusLevel = Configure::read('Status.level', 'all');
174
            if ($statusLevel === 'on') {
175
                $conditions[] = $builder->term('status', 'on');
176
            } elseif ($statusLevel === 'draft') {
177
                $conditions[] = $builder->terms('status', ['on', 'draft']);
178
            }
179
180
            // Filter by publication date.
181
            if ((bool)Configure::read('Publish.checkDate', false)) {
182
                $now = FrozenTime::now();
183
184
                $conditions[] = $builder
185
                    ->or($builder->not($builder->exists('publish_start')), $builder->lte('publish_start', $now))
186
                    ->setMinimumShouldMatch(1);
187
                $conditions[] = $builder
188
                    ->or($builder->not($builder->exists('publish_end')), $builder->gte('publish_end', $now))
189
                    ->setMinimumShouldMatch(1);
190
            }
191
192
            return $builder->and(...$conditions);
193
        });
194
    }
195
196
    /**
197
     * Filter by object type.
198
     *
199
     * @param \Cake\ElasticSearch\Query $query Query object instance.
200
     * @param array{type: string|string[]} $options Finder options.
201
     * @return \Cake\ElasticSearch\Query
202
     */
203
    protected function findType(Query $query, array $options): Query
204
    {
205
        if (empty($options['type'])) {
206
            throw new InvalidArgumentException('Missing or empty `type` option');
207
        }
208
209
        return $query->andWhere(
210
            fn (QueryBuilder $builder): AbstractQuery => $builder->terms('type', (array)$options['type']),
211
        );
212
    }
213
}
214