]> BookStack Code Mirror - bookstack/blob - app/Entities/Tools/SearchRunner.php
Update TrashCan.php
[bookstack] / app / Entities / Tools / SearchRunner.php
1 <?php namespace BookStack\Entities\Tools;
2
3 use BookStack\Auth\Permissions\PermissionService;
4 use BookStack\Entities\EntityProvider;
5 use BookStack\Entities\Models\Entity;
6 use Illuminate\Database\Connection;
7 use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
8 use Illuminate\Database\Query\Builder;
9 use Illuminate\Database\Query\JoinClause;
10 use Illuminate\Support\Collection;
11 use Illuminate\Support\Str;
12
13 class SearchRunner
14 {
15
16     /**
17      * @var EntityProvider
18      */
19     protected $entityProvider;
20
21     /**
22      * @var Connection
23      */
24     protected $db;
25
26     /**
27      * @var PermissionService
28      */
29     protected $permissionService;
30
31
32     /**
33      * Acceptable operators to be used in a query
34      * @var array
35      */
36     protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
37
38
39     public function __construct(EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
40     {
41         $this->entityProvider = $entityProvider;
42         $this->db = $db;
43         $this->permissionService = $permissionService;
44     }
45
46     /**
47      * Search all entities in the system.
48      * The provided count is for each entity to search,
49      * Total returned could can be larger and not guaranteed.
50      */
51     public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array
52     {
53         $entityTypes = array_keys($this->entityProvider->all());
54         $entityTypesToSearch = $entityTypes;
55
56         if ($entityType !== 'all') {
57             $entityTypesToSearch = $entityType;
58         } else if (isset($searchOpts->filters['type'])) {
59             $entityTypesToSearch = explode('|', $searchOpts->filters['type']);
60         }
61
62         $results = collect();
63         $total = 0;
64         $hasMore = false;
65
66         foreach ($entityTypesToSearch as $entityType) {
67             if (!in_array($entityType, $entityTypes)) {
68                 continue;
69             }
70             $search = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action);
71             $entityTotal = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action, true);
72             if ($entityTotal > $page * $count) {
73                 $hasMore = true;
74             }
75             $total += $entityTotal;
76             $results = $results->merge($search);
77         }
78
79         return [
80             'total' => $total,
81             'count' => count($results),
82             'has_more' => $hasMore,
83             'results' => $results->sortByDesc('score')->values(),
84         ];
85     }
86
87
88     /**
89      * Search a book for entities
90      */
91     public function searchBook(int $bookId, string $searchString): Collection
92     {
93         $opts = SearchOptions::fromString($searchString);
94         $entityTypes = ['page', 'chapter'];
95         $entityTypesToSearch = isset($opts->filters['type']) ? explode('|', $opts->filters['type']) : $entityTypes;
96
97         $results = collect();
98         foreach ($entityTypesToSearch as $entityType) {
99             if (!in_array($entityType, $entityTypes)) {
100                 continue;
101             }
102             $search = $this->buildEntitySearchQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
103             $results = $results->merge($search);
104         }
105
106         return $results->sortByDesc('score')->take(20);
107     }
108
109     /**
110      * Search a chapter for entities
111      */
112     public function searchChapter(int $chapterId, string $searchString): Collection
113     {
114         $opts = SearchOptions::fromString($searchString);
115         $pages = $this->buildEntitySearchQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
116         return $pages->sortByDesc('score');
117     }
118
119     /**
120      * Search across a particular entity type.
121      * Setting getCount = true will return the total
122      * matching instead of the items themselves.
123      * @return \Illuminate\Database\Eloquent\Collection|int|static[]
124      */
125     protected function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
126     {
127         $query = $this->buildEntitySearchQuery($searchOpts, $entityType, $action);
128         if ($getCount) {
129             return $query->count();
130         }
131
132         $query = $query->skip(($page-1) * $count)->take($count);
133         return $query->get();
134     }
135
136     /**
137      * Create a search query for an entity
138      */
139     protected function buildEntitySearchQuery(SearchOptions $searchOpts, string $entityType = 'page', string $action = 'view'): EloquentBuilder
140     {
141         $entity = $this->entityProvider->get($entityType);
142         $entitySelect = $entity->newQuery();
143
144         // Handle normal search terms
145         if (count($searchOpts->searches) > 0) {
146             $rawScoreSum = $this->db->raw('SUM(score) as score');
147             $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', $rawScoreSum);
148             $subQuery->where('entity_type', '=', $entity->getMorphClass());
149             $subQuery->where(function (Builder $query) use ($searchOpts) {
150                 foreach ($searchOpts->searches as $inputTerm) {
151                     $query->orWhere('term', 'like', $inputTerm .'%');
152                 }
153             })->groupBy('entity_type', 'entity_id');
154             $entitySelect->join($this->db->raw('(' . $subQuery->toSql() . ') as s'), function (JoinClause $join) {
155                 $join->on('id', '=', 'entity_id');
156             })->selectRaw($entity->getTable().'.*, s.score')->orderBy('score', 'desc');
157             $entitySelect->mergeBindings($subQuery);
158         }
159
160         // Handle exact term matching
161         foreach ($searchOpts->exacts as $inputTerm) {
162             $entitySelect->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
163                 $query->where('name', 'like', '%'.$inputTerm .'%')
164                     ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
165             });
166         }
167
168         // Handle tag searches
169         foreach ($searchOpts->tags as $inputTerm) {
170             $this->applyTagSearch($entitySelect, $inputTerm);
171         }
172
173         // Handle filters
174         foreach ($searchOpts->filters as $filterTerm => $filterValue) {
175             $functionName = Str::camel('filter_' . $filterTerm);
176             if (method_exists($this, $functionName)) {
177                 $this->$functionName($entitySelect, $entity, $filterValue);
178             }
179         }
180
181         return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action);
182     }
183
184     /**
185      * Get the available query operators as a regex escaped list.
186      */
187     protected function getRegexEscapedOperators(): string
188     {
189         $escapedOperators = [];
190         foreach ($this->queryOperators as $operator) {
191             $escapedOperators[] = preg_quote($operator);
192         }
193         return join('|', $escapedOperators);
194     }
195
196     /**
197      * Apply a tag search term onto a entity query.
198      */
199     protected function applyTagSearch(EloquentBuilder $query, string $tagTerm): EloquentBuilder
200     {
201         preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit);
202         $query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
203             $tagName = $tagSplit[1];
204             $tagOperator = count($tagSplit) > 2 ? $tagSplit[3] : '';
205             $tagValue = count($tagSplit) > 3 ? $tagSplit[4] : '';
206             $validOperator = in_array($tagOperator, $this->queryOperators);
207             if (!empty($tagOperator) && !empty($tagValue) && $validOperator) {
208                 if (!empty($tagName)) {
209                     $query->where('name', '=', $tagName);
210                 }
211                 if (is_numeric($tagValue) && $tagOperator !== 'like') {
212                     // We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
213                     // search the value as a string which prevents being able to do number-based operations
214                     // on the tag values. We ensure it has a numeric value and then cast it just to be sure.
215                     $tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'");
216                     $query->whereRaw("value ${tagOperator} ${tagValue}");
217                 } else {
218                     $query->where('value', $tagOperator, $tagValue);
219                 }
220             } else {
221                 $query->where('name', '=', $tagName);
222             }
223         });
224         return $query;
225     }
226
227     /**
228      * Custom entity search filters
229      */
230
231     protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, $input)
232     {
233         try {
234             $date = date_create($input);
235         } catch (\Exception $e) {
236             return;
237         }
238         $query->where('updated_at', '>=', $date);
239     }
240
241     protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, $input)
242     {
243         try {
244             $date = date_create($input);
245         } catch (\Exception $e) {
246             return;
247         }
248         $query->where('updated_at', '<', $date);
249     }
250
251     protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, $input)
252     {
253         try {
254             $date = date_create($input);
255         } catch (\Exception $e) {
256             return;
257         }
258         $query->where('created_at', '>=', $date);
259     }
260
261     protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, $input)
262     {
263         try {
264             $date = date_create($input);
265         } catch (\Exception $e) {
266             return;
267         }
268         $query->where('created_at', '<', $date);
269     }
270
271     protected function filterCreatedBy(EloquentBuilder $query, Entity $model, $input)
272     {
273         if (!is_numeric($input) && $input !== 'me') {
274             return;
275         }
276         if ($input === 'me') {
277             $input = user()->id;
278         }
279         $query->where('created_by', '=', $input);
280     }
281
282     protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, $input)
283     {
284         if (!is_numeric($input) && $input !== 'me') {
285             return;
286         }
287         if ($input === 'me') {
288             $input = user()->id;
289         }
290         $query->where('updated_by', '=', $input);
291     }
292
293     protected function filterInName(EloquentBuilder $query, Entity $model, $input)
294     {
295         $query->where('name', 'like', '%' .$input. '%');
296     }
297
298     protected function filterInTitle(EloquentBuilder $query, Entity $model, $input)
299     {
300         $this->filterInName($query, $model, $input);
301     }
302
303     protected function filterInBody(EloquentBuilder $query, Entity $model, $input)
304     {
305         $query->where($model->textField, 'like', '%' .$input. '%');
306     }
307
308     protected function filterIsRestricted(EloquentBuilder $query, Entity $model, $input)
309     {
310         $query->where('restricted', '=', true);
311     }
312
313     protected function filterViewedByMe(EloquentBuilder $query, Entity $model, $input)
314     {
315         $query->whereHas('views', function ($query) {
316             $query->where('user_id', '=', user()->id);
317         });
318     }
319
320     protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, $input)
321     {
322         $query->whereDoesntHave('views', function ($query) {
323             $query->where('user_id', '=', user()->id);
324         });
325     }
326
327     protected function filterSortBy(EloquentBuilder $query, Entity $model, $input)
328     {
329         $functionName = Str::camel('sort_by_' . $input);
330         if (method_exists($this, $functionName)) {
331             $this->$functionName($query, $model);
332         }
333     }
334
335
336     /**
337      * Sorting filter options
338      */
339
340     protected function sortByLastCommented(EloquentBuilder $query, Entity $model)
341     {
342         $commentsTable = $this->db->getTablePrefix() . 'comments';
343         $morphClass = str_replace('\\', '\\\\', $model->getMorphClass());
344         $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');
345
346         $query->join($commentQuery, $model->getTable() . '.id', '=', 'comments.entity_id')->orderBy('last_commented', 'desc');
347     }
348 }