]> BookStack Code Mirror - bookstack/blob - app/Entities/Tools/SearchRunner.php
Fixed some typos and corrected grammar.
[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\BookChild;
9 use BookStack\Entities\Models\Entity;
10 use BookStack\Entities\Models\Page;
11 use BookStack\Entities\Models\SearchTerm;
12 use Illuminate\Database\Connection;
13 use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
14 use Illuminate\Database\Eloquent\Collection as EloquentCollection;
15 use Illuminate\Database\Eloquent\Relations\BelongsTo;
16 use Illuminate\Database\Query\Builder;
17 use Illuminate\Support\Collection;
18 use Illuminate\Support\Facades\DB;
19 use Illuminate\Support\Str;
20 use SplObjectStorage;
21
22 class SearchRunner
23 {
24     /**
25      * @var EntityProvider
26      */
27     protected $entityProvider;
28
29     /**
30      * @var PermissionService
31      */
32     protected $permissionService;
33
34     /**
35      * Acceptable operators to be used in a query.
36      *
37      * @var array
38      */
39     protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
40
41     /**
42      * Retain a cache of score adjusted terms for specific search options.
43      * From PHP>=8 this can be made into a WeakMap instead.
44      *
45      * @var SplObjectStorage
46      */
47     protected $termAdjustmentCache;
48
49     public function __construct(EntityProvider $entityProvider, PermissionService $permissionService)
50     {
51         $this->entityProvider = $entityProvider;
52         $this->permissionService = $permissionService;
53         $this->termAdjustmentCache = new SplObjectStorage();
54     }
55
56     /**
57      * Search all entities in the system.
58      * The provided count is for each entity to search,
59      * Total returned could be larger and not guaranteed.
60      *
61      * @return array{total: int, count: int, has_more: bool, results: Entity[]}
62      */
63     public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array
64     {
65         $entityTypes = array_keys($this->entityProvider->all());
66         $entityTypesToSearch = $entityTypes;
67
68         if ($entityType !== 'all') {
69             $entityTypesToSearch = $entityType;
70         } elseif (isset($searchOpts->filters['type'])) {
71             $entityTypesToSearch = explode('|', $searchOpts->filters['type']);
72         }
73
74         $results = collect();
75         $total = 0;
76         $hasMore = false;
77
78         foreach ($entityTypesToSearch as $entityType) {
79             if (!in_array($entityType, $entityTypes)) {
80                 continue;
81             }
82
83             $entityModelInstance = $this->entityProvider->get($entityType);
84             $searchQuery = $this->buildQuery($searchOpts, $entityModelInstance, $action);
85             $entityTotal = $searchQuery->count();
86             $searchResults = $this->getPageOfDataFromQuery($searchQuery, $entityModelInstance, $page, $count);
87
88             if ($entityTotal > ($page * $count)) {
89                 $hasMore = true;
90             }
91
92             $total += $entityTotal;
93             $results = $results->merge($searchResults);
94         }
95
96         return [
97             'total'    => $total,
98             'count'    => count($results),
99             'has_more' => $hasMore,
100             'results'  => $results->sortByDesc('score')->values(),
101         ];
102     }
103
104     /**
105      * Search a book for entities.
106      */
107     public function searchBook(int $bookId, string $searchString): Collection
108     {
109         $opts = SearchOptions::fromString($searchString);
110         $entityTypes = ['page', 'chapter'];
111         $entityTypesToSearch = isset($opts->filters['type']) ? explode('|', $opts->filters['type']) : $entityTypes;
112
113         $results = collect();
114         foreach ($entityTypesToSearch as $entityType) {
115             if (!in_array($entityType, $entityTypes)) {
116                 continue;
117             }
118
119             $entityModelInstance = $this->entityProvider->get($entityType);
120             $search = $this->buildQuery($opts, $entityModelInstance)->where('book_id', '=', $bookId)->take(20)->get();
121             $results = $results->merge($search);
122         }
123
124         return $results->sortByDesc('score')->take(20);
125     }
126
127     /**
128      * Search a chapter for entities.
129      */
130     public function searchChapter(int $chapterId, string $searchString): Collection
131     {
132         $opts = SearchOptions::fromString($searchString);
133         $entityModelInstance = $this->entityProvider->get('page');
134         $pages = $this->buildQuery($opts, $entityModelInstance)->where('chapter_id', '=', $chapterId)->take(20)->get();
135
136         return $pages->sortByDesc('score');
137     }
138
139     /**
140      * Get a page of result data from the given query based on the provided page parameters.
141      */
142     protected function getPageOfDataFromQuery(EloquentBuilder $query, Entity $entityModelInstance, int $page = 1, int $count = 20): EloquentCollection
143     {
144         $relations = ['tags'];
145
146         if ($entityModelInstance instanceof BookChild) {
147             $relations['book'] = function (BelongsTo $query) {
148                 $query->scopes('visible');
149             };
150         }
151
152         if ($entityModelInstance instanceof Page) {
153             $relations['chapter'] = function (BelongsTo $query) {
154                 $query->scopes('visible');
155             };
156         }
157
158         return $query->clone()
159             ->with(array_filter($relations))
160             ->skip(($page - 1) * $count)
161             ->take($count)
162             ->get();
163     }
164
165     /**
166      * Create a search query for an entity.
167      */
168     protected function buildQuery(SearchOptions $searchOpts, Entity $entityModelInstance, string $action = 'view'): EloquentBuilder
169     {
170         $entityQuery = $entityModelInstance->newQuery();
171
172         if ($entityModelInstance instanceof Page) {
173             $entityQuery->select($entityModelInstance::$listAttributes);
174         } else {
175             $entityQuery->select(['*']);
176         }
177
178         // Handle normal search terms
179         $this->applyTermSearch($entityQuery, $searchOpts, $entityModelInstance);
180
181         // Handle exact term matching
182         foreach ($searchOpts->exacts as $inputTerm) {
183             $entityQuery->where(function (EloquentBuilder $query) use ($inputTerm, $entityModelInstance) {
184                 $query->where('name', 'like', '%' . $inputTerm . '%')
185                     ->orWhere($entityModelInstance->textField, 'like', '%' . $inputTerm . '%');
186             });
187         }
188
189         // Handle tag searches
190         foreach ($searchOpts->tags as $inputTerm) {
191             $this->applyTagSearch($entityQuery, $inputTerm);
192         }
193
194         // Handle filters
195         foreach ($searchOpts->filters as $filterTerm => $filterValue) {
196             $functionName = Str::camel('filter_' . $filterTerm);
197             if (method_exists($this, $functionName)) {
198                 $this->$functionName($entityQuery, $entityModelInstance, $filterValue);
199             }
200         }
201
202         return $this->permissionService->enforceEntityRestrictions($entityModelInstance, $entityQuery, $action);
203     }
204
205     /**
206      * For the given search query, apply the queries for handling the regular search terms.
207      */
208     protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, Entity $entity): void
209     {
210         $terms = $options->searches;
211         if (count($terms) === 0) {
212             return;
213         }
214
215         $scoredTerms = $this->getTermAdjustments($options);
216         $scoreSelect = $this->selectForScoredTerms($scoredTerms);
217
218         $subQuery = DB::table('search_terms')->select([
219             'entity_id',
220             'entity_type',
221             DB::raw($scoreSelect['statement']),
222         ]);
223
224         $subQuery->addBinding($scoreSelect['bindings'], 'select');
225
226         $subQuery->where('entity_type', '=', $entity->getMorphClass());
227         $subQuery->where(function (Builder $query) use ($terms) {
228             foreach ($terms as $inputTerm) {
229                 $query->orWhere('term', 'like', $inputTerm . '%');
230             }
231         });
232         $subQuery->groupBy('entity_type', 'entity_id');
233
234         $entityQuery->joinSub($subQuery, 's', 'id', '=', 'entity_id');
235         $entityQuery->addSelect('s.score');
236         $entityQuery->orderBy('score', 'desc');
237     }
238
239     /**
240      * Create a select statement, with prepared bindings, for the given
241      * set of scored search terms.
242      *
243      * @param array<string, float> $scoredTerms
244      *
245      * @return array{statement: string, bindings: string[]}
246      */
247     protected function selectForScoredTerms(array $scoredTerms): array
248     {
249         // Within this we walk backwards to create the chain of 'if' statements
250         // so that each previous statement is used in the 'else' condition of
251         // the next (earlier) to be built. We start at '0' to have no score
252         // on no match (Should never actually get to this case).
253         $ifChain = '0';
254         $bindings = [];
255         foreach ($scoredTerms as $term => $score) {
256             $ifChain = 'IF(term like ?, score * ' . (float) $score . ', ' . $ifChain . ')';
257             $bindings[] = $term . '%';
258         }
259
260         return [
261             'statement' => 'SUM(' . $ifChain . ') as score',
262             'bindings'  => array_reverse($bindings),
263         ];
264     }
265
266     /**
267      * For the terms in the given search options, query their popularity across all
268      * search terms then provide that back as score adjustment multiplier applicable
269      * for their rarity. Returns an array of float multipliers, keyed by term.
270      *
271      * @return array<string, float>
272      */
273     protected function getTermAdjustments(SearchOptions $options): array
274     {
275         if (isset($this->termAdjustmentCache[$options])) {
276             return $this->termAdjustmentCache[$options];
277         }
278
279         $termQuery = SearchTerm::query()->toBase();
280         $whenStatements = [];
281         $whenBindings = [];
282
283         foreach ($options->searches as $term) {
284             $whenStatements[] = 'WHEN term LIKE ? THEN ?';
285             $whenBindings[] = $term . '%';
286             $whenBindings[] = $term;
287
288             $termQuery->orWhere('term', 'like', $term . '%');
289         }
290
291         $case = 'CASE ' . implode(' ', $whenStatements) . ' END';
292         $termQuery->selectRaw($case . ' as term', $whenBindings);
293         $termQuery->selectRaw('COUNT(*) as count');
294         $termQuery->groupByRaw($case, $whenBindings);
295
296         $termCounts = $termQuery->pluck('count', 'term')->toArray();
297         $adjusted = $this->rawTermCountsToAdjustments($termCounts);
298
299         $this->termAdjustmentCache[$options] = $adjusted;
300
301         return $this->termAdjustmentCache[$options];
302     }
303
304     /**
305      * Convert counts of terms into a relative-count normalised multiplier.
306      *
307      * @param array<string, int> $termCounts
308      *
309      * @return array<string, int>
310      */
311     protected function rawTermCountsToAdjustments(array $termCounts): array
312     {
313         if (empty($termCounts)) {
314             return [];
315         }
316
317         $multipliers = [];
318         $max = max(array_values($termCounts));
319
320         foreach ($termCounts as $term => $count) {
321             $percent = round($count / $max, 5);
322             $multipliers[$term] = 1.3 - $percent;
323         }
324
325         return $multipliers;
326     }
327
328     /**
329      * Get the available query operators as a regex escaped list.
330      */
331     protected function getRegexEscapedOperators(): string
332     {
333         $escapedOperators = [];
334         foreach ($this->queryOperators as $operator) {
335             $escapedOperators[] = preg_quote($operator);
336         }
337
338         return implode('|', $escapedOperators);
339     }
340
341     /**
342      * Apply a tag search term onto a entity query.
343      */
344     protected function applyTagSearch(EloquentBuilder $query, string $tagTerm): EloquentBuilder
345     {
346         preg_match('/^(.*?)((' . $this->getRegexEscapedOperators() . ')(.*?))?$/', $tagTerm, $tagSplit);
347         $query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
348             $tagName = $tagSplit[1];
349             $tagOperator = count($tagSplit) > 2 ? $tagSplit[3] : '';
350             $tagValue = count($tagSplit) > 3 ? $tagSplit[4] : '';
351             $validOperator = in_array($tagOperator, $this->queryOperators);
352             if (!empty($tagOperator) && !empty($tagValue) && $validOperator) {
353                 if (!empty($tagName)) {
354                     $query->where('name', '=', $tagName);
355                 }
356                 if (is_numeric($tagValue) && $tagOperator !== 'like') {
357                     // We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
358                     // search the value as a string which prevents being able to do number-based operations
359                     // on the tag values. We ensure it has a numeric value and then cast it just to be sure.
360                     /** @var Connection $connection */
361                     $connection = $query->getConnection();
362                     $tagValue = (float) trim($connection->getPdo()->quote($tagValue), "'");
363                     $query->whereRaw("value {$tagOperator} {$tagValue}");
364                 } else {
365                     $query->where('value', $tagOperator, $tagValue);
366                 }
367             } else {
368                 $query->where('name', '=', $tagName);
369             }
370         });
371
372         return $query;
373     }
374
375     /**
376      * Custom entity search filters.
377      */
378     protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, $input): void
379     {
380         try {
381             $date = date_create($input);
382             $query->where('updated_at', '>=', $date);
383         } catch (\Exception $e) {
384         }
385     }
386
387     protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, $input): void
388     {
389         try {
390             $date = date_create($input);
391             $query->where('updated_at', '<', $date);
392         } catch (\Exception $e) {
393         }
394     }
395
396     protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, $input): void
397     {
398         try {
399             $date = date_create($input);
400             $query->where('created_at', '>=', $date);
401         } catch (\Exception $e) {
402         }
403     }
404
405     protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, $input)
406     {
407         try {
408             $date = date_create($input);
409             $query->where('created_at', '<', $date);
410         } catch (\Exception $e) {
411         }
412     }
413
414     protected function filterCreatedBy(EloquentBuilder $query, Entity $model, $input)
415     {
416         $userSlug = $input === 'me' ? user()->slug : trim($input);
417         $user = User::query()->where('slug', '=', $userSlug)->first(['id']);
418         if ($user) {
419             $query->where('created_by', '=', $user->id);
420         }
421     }
422
423     protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, $input)
424     {
425         $userSlug = $input === 'me' ? user()->slug : trim($input);
426         $user = User::query()->where('slug', '=', $userSlug)->first(['id']);
427         if ($user) {
428             $query->where('updated_by', '=', $user->id);
429         }
430     }
431
432     protected function filterOwnedBy(EloquentBuilder $query, Entity $model, $input)
433     {
434         $userSlug = $input === 'me' ? user()->slug : trim($input);
435         $user = User::query()->where('slug', '=', $userSlug)->first(['id']);
436         if ($user) {
437             $query->where('owned_by', '=', $user->id);
438         }
439     }
440
441     protected function filterInName(EloquentBuilder $query, Entity $model, $input)
442     {
443         $query->where('name', 'like', '%' . $input . '%');
444     }
445
446     protected function filterInTitle(EloquentBuilder $query, Entity $model, $input)
447     {
448         $this->filterInName($query, $model, $input);
449     }
450
451     protected function filterInBody(EloquentBuilder $query, Entity $model, $input)
452     {
453         $query->where($model->textField, 'like', '%' . $input . '%');
454     }
455
456     protected function filterIsRestricted(EloquentBuilder $query, Entity $model, $input)
457     {
458         $query->where('restricted', '=', true);
459     }
460
461     protected function filterViewedByMe(EloquentBuilder $query, Entity $model, $input)
462     {
463         $query->whereHas('views', function ($query) {
464             $query->where('user_id', '=', user()->id);
465         });
466     }
467
468     protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, $input)
469     {
470         $query->whereDoesntHave('views', function ($query) {
471             $query->where('user_id', '=', user()->id);
472         });
473     }
474
475     protected function filterSortBy(EloquentBuilder $query, Entity $model, $input)
476     {
477         $functionName = Str::camel('sort_by_' . $input);
478         if (method_exists($this, $functionName)) {
479             $this->$functionName($query, $model);
480         }
481     }
482
483     /**
484      * Sorting filter options.
485      */
486     protected function sortByLastCommented(EloquentBuilder $query, Entity $model)
487     {
488         $commentsTable = DB::getTablePrefix() . 'comments';
489         $morphClass = str_replace('\\', '\\\\', $model->getMorphClass());
490         $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');
491
492         $query->join($commentQuery, $model->getTable() . '.id', '=', 'comments.entity_id')->orderBy('last_commented', 'desc');
493     }
494 }