SearchableBehavior::beforeDelete()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 5
nc 1
nop 3
dl 0
loc 8
rs 9.4285
c 0
b 0
f 0
1
<?php
2
/**
3
 * Licensed under The GPL-3.0 License
4
 * For full copyright and license information, please see the LICENSE.txt
5
 * Redistributions of files must retain the above copyright notice.
6
 *
7
 * @since    2.0.0
8
 * @author   Christopher Castro <[email protected]>
9
 * @link     http://www.quickappscms.org
10
 * @license  http://opensource.org/licenses/gpl-3.0.html GPL-3.0 License
11
 */
12
namespace Search\Model\Behavior;
13
14
use Cake\Datasource\EntityInterface;
15
use Cake\Event\Event;
16
use Cake\Event\EventManager;
17
use Cake\ORM\Behavior;
18
use Cake\ORM\Query;
19
use Cake\ORM\Table;
20
use Cake\Utility\Inflector;
21
use Search\Engine\EngineInterface;
22
use Search\Error\EngineNotFoundException;
23
use Search\Operator\BaseOperator;
24
use Search\Parser\TokenInterface;
25
use \ArrayObject;
26
27
/**
28
 * This behavior allows entities to be searchable using interchangeable search
29
 * engines.
30
 *
31
 * By default `GenericEngine` will be used if not provided. New engine interface
32
 * adapters can be created and attached to this behavior, such as `elasticsearch`,
33
 * `Apache SOLR`, `Sphinx`, etc.
34
 */
35
class SearchableBehavior extends Behavior
36
{
37
38
    /**
39
     * Instance of the engine being used.
40
     *
41
     * @var null|\Search\Engine\EngineInterface
42
     */
43
    protected $_engine = null;
44
45
    /**
46
     * Behavior configuration array.
47
     *
48
     * - `indexOn`: Indicates when entities should be indexed. `update` when entities are
49
     *   being updated, `insert` when new entities are persisted into DB. Or `both` (by
50
     *   default).
51
     *
52
     * - `engine`: Search engine adapter information. Defaults to `Search.Generic`.
53
     *
54
     *    - `className`: Engine's fully qualified class name.
55
     *    - `config`: Options for engine's constructor.
56
     *
57
     * @var array
58
     */
59
    protected $_defaultConfig = [
60
        'engine' => [
61
            'className' => 'Search\\Engine\\Generic\\GenericEngine',
62
            'config' => [
63
                'operators' => [],
64
                'strict' => [],
65
                'bannedWords' => [],
66
            ]
67
        ],
68
        'indexOn' => 'both',
69
        'implementedMethods' => [
70
            'searchEngine' => 'searchEngine',
71
            'search' => 'search',
72
            'applySearchOperator' => 'applySearchOperator',
73
            'addSearchOperator' => 'addSearchOperator',
74
            'enableSearchOperator' => 'enableSearchOperator',
75
            'disableSearchOperator' => 'disableSearchOperator',
76
        ],
77
    ];
78
79
    /**
80
     * Constructor.
81
     *
82
     * @param \Cake\ORM\Table $table The table this behavior is attached to.
83
     * @param array $config The config for this behavior.
84
     * @throws \Search\Error\EngineNotFoundException When no engine was
85
     *  configured
86
     */
87
    public function __construct(Table $table, array $config = [])
88
    {
89
        parent::__construct($table, $config);
90
        $engineClass = $this->config('engine.className');
91
        $engineClass = empty($engineClass) ? 'Search\\Engine\\Generic\\GenericEngine' : $engineClass;
92
        if (!class_exists($engineClass)) {
93
            throw new EngineNotFoundException(__d('search', 'The search engine "{0}" was not found.', $engineClass));
94
        }
95
96
        $this->_engine = new $engineClass($table, (array)$this->config('engine.config'));
97
    }
98
99
    /**
100
     * {@inheritDoc}
101
     */
102
    public function implementedEvents()
103
    {
104
        $events = parent::implementedEvents();
105
        $events['afterSave'] = [
106
            'callable' => 'afterSave',
107
            'priority' => 500,
108
            'passParams' => true
109
        ];
110
111
        return $events;
112
    }
113
114
    /**
115
     * Generates a list of words after each entity is saved.
116
     *
117
     * Triggers the following events:
118
     *
119
     * - `Model.beforeIndex`: Before entity gets indexed by the configured search
120
     *   engine adapter. First argument is the entity instance being indexed.
121
     *
122
     * - `Model.afterIndex`: After entity was indexed by the configured search
123
     *   engine adapter. First argument is the entity instance that was indexed, and
124
     *   second indicates whether the indexing process completed correctly or not.
125
     *
126
     * @param \Cake\Event\Event $event The event that was triggered
127
     * @param \Cake\Datasource\EntityInterface $entity The entity that was saved
128
     * @param \ArrayObject $options Additional options
129
     * @return void
130
     */
131
    public function afterSave(Event $event, EntityInterface $entity, ArrayObject $options)
132
    {
133
        $isNew = $entity->isNew();
134
        if (($this->config('on') === 'update' && $isNew) ||
135
            ($this->config('on') === 'insert' && !$isNew) ||
136
            (isset($options['index']) && $options['index'] === false)
137
        ) {
138
            return;
139
        }
140
141
        $this->_table->dispatchEvent('Model.beforeIndex', compact('entity'));
142
        $success = $this->searchEngine()->index($entity);
143
        $this->_table->dispatchEvent('Model.afterIndex', compact('entity', 'success'));
144
    }
145
146
    /**
147
     * Prepares entity to delete its words-index.
148
     *
149
     * Triggers the following events:
150
     *
151
     * - `Model.beforeRemoveIndex`: Before entity's index is removed. First argument
152
     *   is the affected entity instance.
153
     *
154
     * - `Model.afterRemoveIndex`: After entity's index is removed. First argument
155
     *   is the affected entity instance, and second indicates whether the
156
     *   index-removing process completed correctly or not.
157
     *
158
     * @param \Cake\Event\Event $event The event that was triggered
159
     * @param \Cake\Datasource\EntityInterface $entity The entity that was removed
160
     * @param \ArrayObject $options Additional options
161
     * @return bool
162
     */
163
    public function beforeDelete(Event $event, EntityInterface $entity, ArrayObject $options)
164
    {
165
        $this->_table->dispatchEvent('Model.beforeRemoveIndex', compact('entity'));
166
        $success = $this->searchEngine()->delete($entity);
167
        $this->_table->dispatchEvent('Model.afterRemoveIndex', compact('entity', 'success'));
168
169
        return $success;
170
    }
171
172
    /**
173
     * Gets entities matching the given search criteria.
174
     *
175
     * @param mixed $criteria A search-criteria compatible with the Search Engine being used
176
     * @param \Cake\ORM\Query|null $query The query to scope, or null to create one
177
     * @param array $options Additional parameter used to control the search process provided by attached Engine
178
     * @return \Cake\ORM\Query Scoped query
179
     * @throws Cake\Error\FatalErrorException When query gets corrupted while processing tokens
180
     */
181
    public function search($criteria, Query $query = null, array $options = [])
182
    {
183
        if ($query === null) {
184
            $query = $this->_table->find();
185
        }
186
187
        return $this->searchEngine()->search($criteria, $query, $options);
188
    }
189
190
    /**
191
     * Gets/sets search engine instance.
192
     *
193
     * @return \Search\Engine\EngineInterface
194
     */
195
    public function searchEngine(EngineInterface $engine = null)
196
    {
197
        if ($engine !== null) {
198
            $this->_engine = $engine;
199
        }
200
201
        return $this->_engine;
202
    }
203
204
    /**
205
     * Registers a new operator method.
206
     *
207
     * Allowed formats are:
208
     *
209
     * ```php
210
     * $this->addSearchOperator('created', 'operatorCreated');
211
     * ```
212
     *
213
     * The above will use Table's `operatorCreated()` method to handle the "created"
214
     * operator.
215
     *
216
     * ---
217
     *
218
     * ```php
219
     * $this->addSearchOperator('created', 'MyPlugin.Limit');
220
     * ```
221
     *
222
     * The above will use `MyPlugin\Model\Search\LimitOperator` class to handle the
223
     * "limit" operator. Note the `Operator` suffix.
224
     *
225
     * ---
226
     *
227
     * ```php
228
     * $this->addSearchOperator('created', 'MyPlugin.Limit', ['my_option' => 'option_value']);
229
     * ```
230
     *
231
     * Similar as before, but in this case you can provide some configuration
232
     * options passing an array as above.
233
     *
234
     * ---
235
     *
236
     * ```php
237
     * $this->addSearchOperator('created', 'Full\ClassName');
238
     * ```
239
     *
240
     * Or you can indicate a full class name to use.
241
     *
242
     * ---
243
     *
244
     * ```php
245
     * $this->addSearchOperator('created', function ($query, $token) {
246
     *     // scope $query
247
     *     return $query;
248
     * });
249
     * ```
250
     *
251
     * You can simply pass a callable function to handle the operator, this callable
252
     * must return the altered $query object.
253
     *
254
     * ---
255
     *
256
     * ```php
257
     * $this->addSearchOperator('created', new CreatedOperator($table, $options));
258
     * ```
259
     *
260
     * In this case you can directly pass an instance of an operator handler,
261
     * this object should extends the `Search\Operator` abstract class.
262
     *
263
     * @param string $name Underscored operator's name. e.g. `author`
264
     * @param mixed $handler A valid handler as described above
265
     * @return void
266
     */
267
    public function addSearchOperator($name, $handler, array $options = [])
268
    {
269
        $name = Inflector::underscore($name);
270
        $operator = [
271
            'name' => $name,
272
            'handler' => false,
273
            'options' => [],
274
        ];
275
276
        if (is_string($handler)) {
277
            if (method_exists($this->_table, $handler)) {
278
                $operator['handler'] = $handler;
279
            } else {
280
                list($plugin, $class) = pluginSplit($handler);
281
282
                if ($plugin) {
283
                    $className = $plugin === 'Search' ? "Search\\Operator\\{$class}Operator" : "{$plugin}\\Model\\Search\\{$class}Operator";
284
                    $className = str_replace('OperatorOperator', 'Operator', $className);
285
                } else {
286
                    $className = $class;
287
                }
288
289
                $operator['handler'] = $className;
290
                $operator['options'] = $options;
291
            }
292
        } elseif (is_object($handler) || is_callable($handler)) {
293
            $operator['handler'] = $handler;
294
        }
295
296
        $this->config("operators.{$name}", $operator);
297
    }
298
299
    /**
300
     * Enables a an operator.
301
     *
302
     * @param string $name Name of the operator to be enabled
303
     * @return void
304
     */
305 View Code Duplication
    public function enableSearchOperator($name)
306
    {
307
        if (isset($this->_config['operators'][":{$name}"])) {
308
            $this->_config['operators'][$name] = $this->_config['operators'][":{$name}"];
309
            unset($this->_config['operators'][":{$name}"]);
310
        }
311
    }
312
313
    /**
314
     * Disables an operator.
315
     *
316
     * @param string $name Name of the operator to be disabled
317
     * @return void
318
     */
319 View Code Duplication
    public function disableSearchOperator($name)
320
    {
321
        if (isset($this->_config['operators'][$name])) {
322
            $this->_config['operators'][":{$name}"] = $this->_config['operators'][$name];
323
            unset($this->_config['operators'][$name]);
324
        }
325
    }
326
327
    /**
328
     * Given a query instance applies the provided token representing a search
329
     * operator.
330
     *
331
     * @param \Cake\ORM\Query $query The query to be scope
332
     * @param \Search\TokenInterface $token Token describing an operator. e.g
333
     *  `-op_name:op_value`
334
     * @return \Cake\ORM\Query Scoped query
335
     */
336
    public function applySearchOperator(Query $query, TokenInterface $token)
337
    {
338
        if (!$token->isOperator()) {
339
            return $query;
340
        }
341
342
        $callable = $this->_operatorCallable($token->name());
343
        if (is_callable($callable)) {
344
            $query = $callable($query, $token);
345
            if (!($query instanceof Query)) {
346
                throw new FatalErrorException(__d('search', 'Error while processing the "{0}" token in the search criteria.', $operator));
347
            }
348
        } else {
349
            $result = $this->_triggerOperator($query, $token);
350
            if ($result instanceof Query) {
351
                $query = $result;
352
            }
353
        }
354
355
        return $query;
356
    }
357
358
    /**
359
     * Triggers an event for handling undefined operators. Event listeners may
360
     * capture this event and provide operator handling logic, such listeners should
361
     * alter the provided Query object and then return it back.
362
     *
363
     * The triggered event follows the pattern:
364
     *
365
     * ```
366
     * Search.operator<CamelCaseOperatorName>
367
     * ```
368
     *
369
     * For example, `Search.operatorAuthorName` will be triggered for
370
     * handling an operator named either `author-name` or `author_name`.
371
     *
372
     * @param \Cake\ORM\Query $query The query that is expected to be scoped
373
     * @param \Search\TokenInterface $token Token describing an operator. e.g `-op_name:op_value`
374
     * @return mixed Scoped query object expected or null if event was not captured by any listener
375
     */
376
    protected function _triggerOperator(Query $query, TokenInterface $token)
377
    {
378
        $eventName = 'Search.' . (string)Inflector::variable('operator_' . $token->name());
379
        $event = new Event($eventName, $this->_table, compact('query', 'token'));
380
381
        return EventManager::instance()->dispatch($event)->result;
382
    }
383
384
    /**
385
     * Gets the callable method for a given operator method.
386
     *
387
     * @param string $name Name of the method to get
388
     * @return bool|callable False if no callback was found for the given operator
389
     *  name. Or the callable if found.
390
     */
391
    protected function _operatorCallable($name)
392
    {
393
        $operator = $this->config("operators.{$name}");
394
395
        if ($operator) {
396
            $handler = $operator['handler'];
397
398
            if (is_callable($handler)) {
399
                return function ($query, $token) use ($handler) {
400
                    return $handler($query, $token);
401
                };
402
            } elseif ($handler instanceof BaseOperator) {
403
                return function ($query, $token) use ($handler) {
404
                    return $handler->scope($query, $token);
405
                };
406
            } elseif (is_string($handler) && method_exists($this->_table, $handler)) {
407
                return function ($query, $token) use ($handler) {
408
                    return $this->_table->$handler($query, $token);
409
                };
410
            } elseif (is_string($handler) && is_subclass_of($handler, '\Search\Operator\BaseOperator')) {
0 ignored issues
show
Bug introduced by
Due to PHP Bug #53727, is_subclass_of returns inconsistent results on some PHP versions for interfaces; you could instead use ReflectionClass::implementsInterface.
Loading history...
411
                return function ($query, $token) use ($operator) {
412
                    $instance = new $operator['handler']($this->_table, $operator['options']);
413
414
                    return $instance->scope($query, $token);
415
                };
416
            }
417
        }
418
419
        return false;
420
    }
421
}
422