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