1 <?php namespace BookStack\Entities;
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;
16 protected $searchTerm;
21 protected $entityProvider;
29 * @var PermissionService
31 protected $permissionService;
35 * Acceptable operators to be used in a query
38 protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
41 * SearchService constructor.
43 public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
45 $this->searchTerm = $searchTerm;
46 $this->entityProvider = $entityProvider;
48 $this->permissionService = $permissionService;
52 * Set the database connection
54 public function setConnection(Connection $connection)
56 $this->db = $connection;
60 * Search all entities in the system.
61 * The provided count is for each entity to search,
62 * Total returned could can be larger and not guaranteed.
64 public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array
66 $entityTypes = array_keys($this->entityProvider->all());
67 $entityTypesToSearch = $entityTypes;
69 if ($entityType !== 'all') {
70 $entityTypesToSearch = $entityType;
71 } else if (isset($searchOpts->filters['type'])) {
72 $entityTypesToSearch = explode('|', $searchOpts->filters['type']);
79 foreach ($entityTypesToSearch as $entityType) {
80 if (!in_array($entityType, $entityTypes)) {
83 $search = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action);
84 $entityTotal = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action, true);
85 if ($entityTotal > $page * $count) {
88 $total += $entityTotal;
89 $results = $results->merge($search);
94 'count' => count($results),
95 'has_more' => $hasMore,
96 'results' => $results->sortByDesc('score')->values(),
102 * Search a book for entities
104 public function searchBook(int $bookId, string $searchString): Collection
106 $opts = SearchOptions::fromString($searchString);
107 $entityTypes = ['page', 'chapter'];
108 $entityTypesToSearch = isset($opts->filters['type']) ? explode('|', $opts->filters['type']) : $entityTypes;
110 $results = collect();
111 foreach ($entityTypesToSearch as $entityType) {
112 if (!in_array($entityType, $entityTypes)) {
115 $search = $this->buildEntitySearchQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
116 $results = $results->merge($search);
118 return $results->sortByDesc('score')->take(20);
122 * Search a book for entities
124 public function searchChapter(int $chapterId, string $searchString): Collection
126 $opts = SearchOptions::fromString($searchString);
127 $pages = $this->buildEntitySearchQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
128 return $pages->sortByDesc('score');
132 * Search across a particular entity type.
133 * Setting getCount = true will return the total
134 * matching instead of the items themselves.
135 * @return \Illuminate\Database\Eloquent\Collection|int|static[]
137 public function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
139 $query = $this->buildEntitySearchQuery($searchOpts, $entityType, $action);
141 return $query->count();
144 $query = $query->skip(($page-1) * $count)->take($count);
145 return $query->get();
149 * Create a search query for an entity
151 protected function buildEntitySearchQuery(SearchOptions $searchOpts, string $entityType = 'page', string $action = 'view'): EloquentBuilder
153 $entity = $this->entityProvider->get($entityType);
154 $entitySelect = $entity->newQuery();
156 // Handle normal search terms
157 if (count($searchOpts->searches) > 0) {
158 $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score'));
159 $subQuery->where('entity_type', '=', $entity->getMorphClass());
160 $subQuery->where(function (Builder $query) use ($searchOpts) {
161 foreach ($searchOpts->searches as $inputTerm) {
162 $query->orWhere('term', 'like', $inputTerm .'%');
164 })->groupBy('entity_type', 'entity_id');
165 $entitySelect->join(\DB::raw('(' . $subQuery->toSql() . ') as s'), function (JoinClause $join) {
166 $join->on('id', '=', 'entity_id');
167 })->selectRaw($entity->getTable().'.*, s.score')->orderBy('score', 'desc');
168 $entitySelect->mergeBindings($subQuery);
171 // Handle exact term matching
172 if (count($searchOpts->exacts) > 0) {
173 $entitySelect->where(function (EloquentBuilder $query) use ($searchOpts, $entity) {
174 foreach ($searchOpts->exacts as $inputTerm) {
175 $query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
176 $query->where('name', 'like', '%'.$inputTerm .'%')
177 ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
183 // Handle tag searches
184 foreach ($searchOpts->tags as $inputTerm) {
185 $this->applyTagSearch($entitySelect, $inputTerm);
189 foreach ($searchOpts->filters as $filterTerm => $filterValue) {
190 $functionName = Str::camel('filter_' . $filterTerm);
191 if (method_exists($this, $functionName)) {
192 $this->$functionName($entitySelect, $entity, $filterValue);
196 return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action);
200 * Get the available query operators as a regex escaped list.
202 protected function getRegexEscapedOperators(): string
204 $escapedOperators = [];
205 foreach ($this->queryOperators as $operator) {
206 $escapedOperators[] = preg_quote($operator);
208 return join('|', $escapedOperators);
212 * Apply a tag search term onto a entity query.
214 protected function applyTagSearch(EloquentBuilder $query, string $tagTerm): EloquentBuilder
216 preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit);
217 $query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
218 $tagName = $tagSplit[1];
219 $tagOperator = count($tagSplit) > 2 ? $tagSplit[3] : '';
220 $tagValue = count($tagSplit) > 3 ? $tagSplit[4] : '';
221 $validOperator = in_array($tagOperator, $this->queryOperators);
222 if (!empty($tagOperator) && !empty($tagValue) && $validOperator) {
223 if (!empty($tagName)) {
224 $query->where('name', '=', $tagName);
226 if (is_numeric($tagValue) && $tagOperator !== 'like') {
227 // We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
228 // search the value as a string which prevents being able to do number-based operations
229 // on the tag values. We ensure it has a numeric value and then cast it just to be sure.
230 $tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'");
231 $query->whereRaw("value ${tagOperator} ${tagValue}");
233 $query->where('value', $tagOperator, $tagValue);
236 $query->where('name', '=', $tagName);
243 * Index the given entity.
245 public function indexEntity(Entity $entity)
247 $this->deleteEntityTerms($entity);
248 $nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
249 $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
250 $terms = array_merge($nameTerms, $bodyTerms);
251 foreach ($terms as $index => $term) {
252 $terms[$index]['entity_type'] = $entity->getMorphClass();
253 $terms[$index]['entity_id'] = $entity->id;
255 $this->searchTerm->newQuery()->insert($terms);
259 * Index multiple Entities at once
260 * @param \BookStack\Entities\Entity[] $entities
262 protected function indexEntities($entities)
265 foreach ($entities as $entity) {
266 $nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
267 $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
268 foreach (array_merge($nameTerms, $bodyTerms) as $term) {
269 $term['entity_id'] = $entity->id;
270 $term['entity_type'] = $entity->getMorphClass();
275 $chunkedTerms = array_chunk($terms, 500);
276 foreach ($chunkedTerms as $termChunk) {
277 $this->searchTerm->newQuery()->insert($termChunk);
282 * Delete and re-index the terms for all entities in the system.
284 public function indexAllEntities()
286 $this->searchTerm->truncate();
288 foreach ($this->entityProvider->all() as $entityModel) {
289 $selectFields = ['id', 'name', $entityModel->textField];
290 $entityModel->newQuery()->select($selectFields)->chunk(1000, function ($entities) {
291 $this->indexEntities($entities);
297 * Delete related Entity search terms.
298 * @param Entity $entity
300 public function deleteEntityTerms(Entity $entity)
302 $entity->searchTerms()->delete();
306 * Create a scored term array from the given text.
308 * @param float|int $scoreAdjustment
311 protected function generateTermArrayFromText($text, $scoreAdjustment = 1)
313 $tokenMap = []; // {TextToken => OccurrenceCount}
314 $splitChars = " \n\t.,!?:;()[]{}<>`'\"";
315 $token = strtok($text, $splitChars);
317 while ($token !== false) {
318 if (!isset($tokenMap[$token])) {
319 $tokenMap[$token] = 0;
322 $token = strtok($splitChars);
326 foreach ($tokenMap as $token => $count) {
329 'score' => $count * $scoreAdjustment
339 * Custom entity search filters
342 protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, $input)
345 $date = date_create($input);
346 } catch (\Exception $e) {
349 $query->where('updated_at', '>=', $date);
352 protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, $input)
355 $date = date_create($input);
356 } catch (\Exception $e) {
359 $query->where('updated_at', '<', $date);
362 protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, $input)
365 $date = date_create($input);
366 } catch (\Exception $e) {
369 $query->where('created_at', '>=', $date);
372 protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, $input)
375 $date = date_create($input);
376 } catch (\Exception $e) {
379 $query->where('created_at', '<', $date);
382 protected function filterCreatedBy(EloquentBuilder $query, Entity $model, $input)
384 if (!is_numeric($input) && $input !== 'me') {
387 if ($input === 'me') {
390 $query->where('created_by', '=', $input);
393 protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, $input)
395 if (!is_numeric($input) && $input !== 'me') {
398 if ($input === 'me') {
401 $query->where('updated_by', '=', $input);
404 protected function filterInName(EloquentBuilder $query, Entity $model, $input)
406 $query->where('name', 'like', '%' .$input. '%');
409 protected function filterInTitle(EloquentBuilder $query, Entity $model, $input)
411 $this->filterInName($query, $model, $input);
414 protected function filterInBody(EloquentBuilder $query, Entity $model, $input)
416 $query->where($model->textField, 'like', '%' .$input. '%');
419 protected function filterIsRestricted(EloquentBuilder $query, Entity $model, $input)
421 $query->where('restricted', '=', true);
424 protected function filterViewedByMe(EloquentBuilder $query, Entity $model, $input)
426 $query->whereHas('views', function ($query) {
427 $query->where('user_id', '=', user()->id);
431 protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, $input)
433 $query->whereDoesntHave('views', function ($query) {
434 $query->where('user_id', '=', user()->id);
438 protected function filterSortBy(EloquentBuilder $query, Entity $model, $input)
440 $functionName = Str::camel('sort_by_' . $input);
441 if (method_exists($this, $functionName)) {
442 $this->$functionName($query, $model);
448 * Sorting filter options
451 protected function sortByLastCommented(EloquentBuilder $query, Entity $model)
453 $commentsTable = $this->db->getTablePrefix() . 'comments';
454 $morphClass = str_replace('\\', '\\\\', $model->getMorphClass());
455 $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');
457 $query->join($commentQuery, $model->getTable() . '.id', '=', 'comments.entity_id')->orderBy('last_commented', 'desc');