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')) { |
|
|
|
|
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
|
|
|
|