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