]> BookStack Code Mirror - bookstack/blob - app/Entities/SearchService.php
Updated to Laravel 5.8
[bookstack] / app / Entities / SearchService.php
1 <?php namespace BookStack\Entities;
2
3 use BookStack\Auth\Permissions\PermissionService;
4 use Illuminate\Database\Connection;
5 use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
6 use Illuminate\Database\Query\Builder;
7 use Illuminate\Database\Query\JoinClause;
8 use Illuminate\Support\Collection;
9 use Illuminate\Support\Str;
10
11 class SearchService
12 {
13     /**
14      * @var SearchTerm
15      */
16     protected $searchTerm;
17
18     /**
19      * @var EntityProvider
20      */
21     protected $entityProvider;
22
23     /**
24      * @var Connection
25      */
26     protected $db;
27
28     /**
29      * @var PermissionService
30      */
31     protected $permissionService;
32
33
34     /**
35      * Acceptable operators to be used in a query
36      * @var array
37      */
38     protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
39
40     /**
41      * SearchService constructor.
42      * @param SearchTerm $searchTerm
43      * @param EntityProvider $entityProvider
44      * @param Connection $db
45      * @param PermissionService $permissionService
46      */
47     public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
48     {
49         $this->searchTerm = $searchTerm;
50         $this->entityProvider = $entityProvider;
51         $this->db = $db;
52         $this->permissionService = $permissionService;
53     }
54
55     /**
56      * Set the database connection
57      * @param Connection $connection
58      */
59     public function setConnection(Connection $connection)
60     {
61         $this->db = $connection;
62     }
63
64     /**
65      * Search all entities in the system.
66      * @param string $searchString
67      * @param string $entityType
68      * @param int $page
69      * @param int $count - Count of each entity to search, Total returned could can be larger and not guaranteed.
70      * @param string $action
71      * @return array[int, Collection];
72      */
73     public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20, $action = 'view')
74     {
75         $terms = $this->parseSearchString($searchString);
76         $entityTypes = array_keys($this->entityProvider->all());
77         $entityTypesToSearch = $entityTypes;
78
79         if ($entityType !== 'all') {
80             $entityTypesToSearch = $entityType;
81         } else if (isset($terms['filters']['type'])) {
82             $entityTypesToSearch = explode('|', $terms['filters']['type']);
83         }
84
85         $results = collect();
86         $total = 0;
87         $hasMore = false;
88
89         foreach ($entityTypesToSearch as $entityType) {
90             if (!in_array($entityType, $entityTypes)) {
91                 continue;
92             }
93             $search = $this->searchEntityTable($terms, $entityType, $page, $count, $action);
94             $entityTotal = $this->searchEntityTable($terms, $entityType, $page, $count, $action, true);
95             if ($entityTotal > $page * $count) {
96                 $hasMore = true;
97             }
98             $total += $entityTotal;
99             $results = $results->merge($search);
100         }
101
102         return [
103             'total' => $total,
104             'count' => count($results),
105             'has_more' => $hasMore,
106             'results' => $results->sortByDesc('score')->values()
107         ];
108     }
109
110
111     /**
112      * Search a book for entities
113      * @param integer $bookId
114      * @param string $searchString
115      * @return Collection
116      */
117     public function searchBook($bookId, $searchString)
118     {
119         $terms = $this->parseSearchString($searchString);
120         $entityTypes = ['page', 'chapter'];
121         $entityTypesToSearch = isset($terms['filters']['type']) ? explode('|', $terms['filters']['type']) : $entityTypes;
122
123         $results = collect();
124         foreach ($entityTypesToSearch as $entityType) {
125             if (!in_array($entityType, $entityTypes)) {
126                 continue;
127             }
128             $search = $this->buildEntitySearchQuery($terms, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
129             $results = $results->merge($search);
130         }
131         return $results->sortByDesc('score')->take(20);
132     }
133
134     /**
135      * Search a book for entities
136      * @param integer $chapterId
137      * @param string $searchString
138      * @return Collection
139      */
140     public function searchChapter($chapterId, $searchString)
141     {
142         $terms = $this->parseSearchString($searchString);
143         $pages = $this->buildEntitySearchQuery($terms, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
144         return $pages->sortByDesc('score');
145     }
146
147     /**
148      * Search across a particular entity type.
149      * @param array $terms
150      * @param string $entityType
151      * @param int $page
152      * @param int $count
153      * @param string $action
154      * @param bool $getCount Return the total count of the search
155      * @return \Illuminate\Database\Eloquent\Collection|int|static[]
156      */
157     public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $action = 'view', $getCount = false)
158     {
159         $query = $this->buildEntitySearchQuery($terms, $entityType, $action);
160         if ($getCount) {
161             return $query->count();
162         }
163
164         $query = $query->skip(($page-1) * $count)->take($count);
165         return $query->get();
166     }
167
168     /**
169      * Create a search query for an entity
170      * @param array $terms
171      * @param string $entityType
172      * @param string $action
173      * @return EloquentBuilder
174      */
175     protected function buildEntitySearchQuery($terms, $entityType = 'page', $action = 'view')
176     {
177         $entity = $this->entityProvider->get($entityType);
178         $entitySelect = $entity->newQuery();
179
180         // Handle normal search terms
181         if (count($terms['search']) > 0) {
182             $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score'));
183             $subQuery->where('entity_type', '=', $entity->getMorphClass());
184             $subQuery->where(function (Builder $query) use ($terms) {
185                 foreach ($terms['search'] as $inputTerm) {
186                     $query->orWhere('term', 'like', $inputTerm .'%');
187                 }
188             })->groupBy('entity_type', 'entity_id');
189             $entitySelect->join(\DB::raw('(' . $subQuery->toSql() . ') as s'), function (JoinClause $join) {
190                 $join->on('id', '=', 'entity_id');
191             })->selectRaw($entity->getTable().'.*, s.score')->orderBy('score', 'desc');
192             $entitySelect->mergeBindings($subQuery);
193         }
194
195         // Handle exact term matching
196         if (count($terms['exact']) > 0) {
197             $entitySelect->where(function (EloquentBuilder $query) use ($terms, $entity) {
198                 foreach ($terms['exact'] as $inputTerm) {
199                     $query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
200                         $query->where('name', 'like', '%'.$inputTerm .'%')
201                             ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
202                     });
203                 }
204             });
205         }
206
207         // Handle tag searches
208         foreach ($terms['tags'] as $inputTerm) {
209             $this->applyTagSearch($entitySelect, $inputTerm);
210         }
211
212         // Handle filters
213         foreach ($terms['filters'] as $filterTerm => $filterValue) {
214             $functionName = Str::camel('filter_' . $filterTerm);
215             if (method_exists($this, $functionName)) {
216                 $this->$functionName($entitySelect, $entity, $filterValue);
217             }
218         }
219
220         return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action);
221     }
222
223
224     /**
225      * Parse a search string into components.
226      * @param $searchString
227      * @return array
228      */
229     protected function parseSearchString($searchString)
230     {
231         $terms = [
232             'search' => [],
233             'exact' => [],
234             'tags' => [],
235             'filters' => []
236         ];
237
238         $patterns = [
239             'exact' => '/"(.*?)"/',
240             'tags' => '/\[(.*?)\]/',
241             'filters' => '/\{(.*?)\}/'
242         ];
243
244         // Parse special terms
245         foreach ($patterns as $termType => $pattern) {
246             $matches = [];
247             preg_match_all($pattern, $searchString, $matches);
248             if (count($matches) > 0) {
249                 $terms[$termType] = $matches[1];
250                 $searchString = preg_replace($pattern, '', $searchString);
251             }
252         }
253
254         // Parse standard terms
255         foreach (explode(' ', trim($searchString)) as $searchTerm) {
256             if ($searchTerm !== '') {
257                 $terms['search'][] = $searchTerm;
258             }
259         }
260
261         // Split filter values out
262         $splitFilters = [];
263         foreach ($terms['filters'] as $filter) {
264             $explodedFilter = explode(':', $filter, 2);
265             $splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
266         }
267         $terms['filters'] = $splitFilters;
268
269         return $terms;
270     }
271
272     /**
273      * Get the available query operators as a regex escaped list.
274      * @return mixed
275      */
276     protected function getRegexEscapedOperators()
277     {
278         $escapedOperators = [];
279         foreach ($this->queryOperators as $operator) {
280             $escapedOperators[] = preg_quote($operator);
281         }
282         return join('|', $escapedOperators);
283     }
284
285     /**
286      * Apply a tag search term onto a entity query.
287      * @param EloquentBuilder $query
288      * @param string $tagTerm
289      * @return mixed
290      */
291     protected function applyTagSearch(EloquentBuilder $query, $tagTerm)
292     {
293         preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit);
294         $query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
295             $tagName = $tagSplit[1];
296             $tagOperator = count($tagSplit) > 2 ? $tagSplit[3] : '';
297             $tagValue = count($tagSplit) > 3 ? $tagSplit[4] : '';
298             $validOperator = in_array($tagOperator, $this->queryOperators);
299             if (!empty($tagOperator) && !empty($tagValue) && $validOperator) {
300                 if (!empty($tagName)) {
301                     $query->where('name', '=', $tagName);
302                 }
303                 if (is_numeric($tagValue) && $tagOperator !== 'like') {
304                     // We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
305                     // search the value as a string which prevents being able to do number-based operations
306                     // on the tag values. We ensure it has a numeric value and then cast it just to be sure.
307                     $tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'");
308                     $query->whereRaw("value ${tagOperator} ${tagValue}");
309                 } else {
310                     $query->where('value', $tagOperator, $tagValue);
311                 }
312             } else {
313                 $query->where('name', '=', $tagName);
314             }
315         });
316         return $query;
317     }
318
319     /**
320      * Index the given entity.
321      * @param Entity $entity
322      */
323     public function indexEntity(Entity $entity)
324     {
325         $this->deleteEntityTerms($entity);
326         $nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
327         $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
328         $terms = array_merge($nameTerms, $bodyTerms);
329         foreach ($terms as $index => $term) {
330             $terms[$index]['entity_type'] = $entity->getMorphClass();
331             $terms[$index]['entity_id'] = $entity->id;
332         }
333         $this->searchTerm->newQuery()->insert($terms);
334     }
335
336     /**
337      * Index multiple Entities at once
338      * @param \BookStack\Entities\Entity[] $entities
339      */
340     protected function indexEntities($entities)
341     {
342         $terms = [];
343         foreach ($entities as $entity) {
344             $nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
345             $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
346             foreach (array_merge($nameTerms, $bodyTerms) as $term) {
347                 $term['entity_id'] = $entity->id;
348                 $term['entity_type'] = $entity->getMorphClass();
349                 $terms[] = $term;
350             }
351         }
352
353         $chunkedTerms = array_chunk($terms, 500);
354         foreach ($chunkedTerms as $termChunk) {
355             $this->searchTerm->newQuery()->insert($termChunk);
356         }
357     }
358
359     /**
360      * Delete and re-index the terms for all entities in the system.
361      */
362     public function indexAllEntities()
363     {
364         $this->searchTerm->truncate();
365
366         foreach ($this->entityProvider->all() as $entityModel) {
367             $selectFields = ['id', 'name', $entityModel->textField];
368             $entityModel->newQuery()->select($selectFields)->chunk(1000, function ($entities) {
369                 $this->indexEntities($entities);
370             });
371         }
372     }
373
374     /**
375      * Delete related Entity search terms.
376      * @param Entity $entity
377      */
378     public function deleteEntityTerms(Entity $entity)
379     {
380         $entity->searchTerms()->delete();
381     }
382
383     /**
384      * Create a scored term array from the given text.
385      * @param $text
386      * @param float|int $scoreAdjustment
387      * @return array
388      */
389     protected function generateTermArrayFromText($text, $scoreAdjustment = 1)
390     {
391         $tokenMap = []; // {TextToken => OccurrenceCount}
392         $splitChars = " \n\t.,!?:;()[]{}<>`'\"";
393         $token = strtok($text, $splitChars);
394
395         while ($token !== false) {
396             if (!isset($tokenMap[$token])) {
397                 $tokenMap[$token] = 0;
398             }
399             $tokenMap[$token]++;
400             $token = strtok($splitChars);
401         }
402
403         $terms = [];
404         foreach ($tokenMap as $token => $count) {
405             $terms[] = [
406                 'term' => $token,
407                 'score' => $count * $scoreAdjustment
408             ];
409         }
410         return $terms;
411     }
412
413
414
415
416     /**
417      * Custom entity search filters
418      */
419
420     protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, $input)
421     {
422         try {
423             $date = date_create($input);
424         } catch (\Exception $e) {
425             return;
426         }
427         $query->where('updated_at', '>=', $date);
428     }
429
430     protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, $input)
431     {
432         try {
433             $date = date_create($input);
434         } catch (\Exception $e) {
435             return;
436         }
437         $query->where('updated_at', '<', $date);
438     }
439
440     protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, $input)
441     {
442         try {
443             $date = date_create($input);
444         } catch (\Exception $e) {
445             return;
446         }
447         $query->where('created_at', '>=', $date);
448     }
449
450     protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, $input)
451     {
452         try {
453             $date = date_create($input);
454         } catch (\Exception $e) {
455             return;
456         }
457         $query->where('created_at', '<', $date);
458     }
459
460     protected function filterCreatedBy(EloquentBuilder $query, Entity $model, $input)
461     {
462         if (!is_numeric($input) && $input !== 'me') {
463             return;
464         }
465         if ($input === 'me') {
466             $input = user()->id;
467         }
468         $query->where('created_by', '=', $input);
469     }
470
471     protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, $input)
472     {
473         if (!is_numeric($input) && $input !== 'me') {
474             return;
475         }
476         if ($input === 'me') {
477             $input = user()->id;
478         }
479         $query->where('updated_by', '=', $input);
480     }
481
482     protected function filterInName(EloquentBuilder $query, Entity $model, $input)
483     {
484         $query->where('name', 'like', '%' .$input. '%');
485     }
486
487     protected function filterInTitle(EloquentBuilder $query, Entity $model, $input)
488     {
489         $this->filterInName($query, $model, $input);
490     }
491
492     protected function filterInBody(EloquentBuilder $query, Entity $model, $input)
493     {
494         $query->where($model->textField, 'like', '%' .$input. '%');
495     }
496
497     protected function filterIsRestricted(EloquentBuilder $query, Entity $model, $input)
498     {
499         $query->where('restricted', '=', true);
500     }
501
502     protected function filterViewedByMe(EloquentBuilder $query, Entity $model, $input)
503     {
504         $query->whereHas('views', function ($query) {
505             $query->where('user_id', '=', user()->id);
506         });
507     }
508
509     protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, $input)
510     {
511         $query->whereDoesntHave('views', function ($query) {
512             $query->where('user_id', '=', user()->id);
513         });
514     }
515
516     protected function filterSortBy(EloquentBuilder $query, Entity $model, $input)
517     {
518         $functionName = Str::camel('sort_by_' . $input);
519         if (method_exists($this, $functionName)) {
520             $this->$functionName($query, $model);
521         }
522     }
523
524
525     /**
526      * Sorting filter options
527      */
528
529     protected function sortByLastCommented(EloquentBuilder $query, Entity $model)
530     {
531         $commentsTable = $this->db->getTablePrefix() . 'comments';
532         $morphClass = str_replace('\\', '\\\\', $model->getMorphClass());
533         $commentQuery = $this->db->raw('(SELECT c1.entity_id, c1.entity_type, c1.created_at as last_commented FROM '.$commentsTable.' c1 LEFT JOIN '.$commentsTable.' c2 ON (c1.entity_id = c2.entity_id AND c1.entity_type = c2.entity_type AND c1.created_at < c2.created_at) WHERE c1.entity_type = \''. $morphClass .'\' AND c2.created_at IS NULL) as comments');
534
535         $query->join($commentQuery, $model->getTable() . '.id', '=', 'comments.entity_id')->orderBy('last_commented', 'desc');
536     }
537 }