Skip to content

Commit 652124a

Browse files
authored
Merge pull request #5854 from BookStackApp/efficient_search
Pagable and efficient search
2 parents c21c36e + 751934c commit 652124a

File tree

11 files changed

+338
-115
lines changed

11 files changed

+338
-115
lines changed

app/Entities/Models/Entity.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,4 +471,17 @@ protected function getContentsAttributes(): array
471471

472472
return $contentFields;
473473
}
474+
475+
/**
476+
* Create a new instance for the given entity type.
477+
*/
478+
public static function instanceFromType(string $type): self
479+
{
480+
return match ($type) {
481+
'page' => new Page(),
482+
'chapter' => new Chapter(),
483+
'book' => new Book(),
484+
'bookshelf' => new Bookshelf(),
485+
};
486+
}
474487
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
namespace BookStack\Entities\Models;
4+
5+
use BookStack\Activity\Models\Tag;
6+
use BookStack\Activity\Models\View;
7+
use BookStack\App\Model;
8+
use BookStack\Permissions\Models\EntityPermission;
9+
use BookStack\Permissions\Models\JointPermission;
10+
use BookStack\Permissions\PermissionApplicator;
11+
use Illuminate\Database\Eloquent\Builder;
12+
use Illuminate\Database\Eloquent\Relations\HasMany;
13+
use Illuminate\Database\Eloquent\Relations\MorphMany;
14+
use Illuminate\Database\Eloquent\SoftDeletes;
15+
16+
/**
17+
* This is a simplistic model interpretation of a generic Entity used to query and represent
18+
* that database abstractly. Generally, this should rarely be used outside queries.
19+
*/
20+
class EntityTable extends Model
21+
{
22+
use SoftDeletes;
23+
24+
protected $table = 'entities';
25+
26+
/**
27+
* Get the entities that are visible to the current user.
28+
*/
29+
public function scopeVisible(Builder $query): Builder
30+
{
31+
return app()->make(PermissionApplicator::class)->restrictEntityQuery($query);
32+
}
33+
34+
/**
35+
* Get the entity jointPermissions this is connected to.
36+
*/
37+
public function jointPermissions(): HasMany
38+
{
39+
return $this->hasMany(JointPermission::class, 'entity_id')
40+
->whereColumn('entity_type', '=', 'entities.type');
41+
}
42+
43+
/**
44+
* Get the Tags that have been assigned to entities.
45+
*/
46+
public function tags(): HasMany
47+
{
48+
return $this->hasMany(Tag::class, 'entity_id')
49+
->whereColumn('entity_type', '=', 'entities.type');
50+
}
51+
52+
/**
53+
* Get the assigned permissions.
54+
*/
55+
public function permissions(): HasMany
56+
{
57+
return $this->hasMany(EntityPermission::class, 'entity_id')
58+
->whereColumn('entity_type', '=', 'entities.type');
59+
}
60+
61+
/**
62+
* Get View objects for this entity.
63+
*/
64+
public function views(): HasMany
65+
{
66+
return $this->hasMany(View::class, 'viewable_id')
67+
->whereColumn('viewable_type', '=', 'entities.type');
68+
}
69+
}

app/Entities/Queries/EntityQueries.php

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
namespace BookStack\Entities\Queries;
44

55
use BookStack\Entities\Models\Entity;
6+
use BookStack\Entities\Models\EntityTable;
67
use Illuminate\Database\Eloquent\Builder;
8+
use Illuminate\Database\Query\Builder as QueryBuilder;
9+
use Illuminate\Database\Query\JoinClause;
10+
use Illuminate\Support\Facades\DB;
711
use InvalidArgumentException;
812

913
class EntityQueries
@@ -32,12 +36,37 @@ public function findVisibleByStringIdentifier(string $identifier): ?Entity
3236
return $queries->findVisibleById($entityId);
3337
}
3438

39+
/**
40+
* Start a query across all entity types.
41+
* Combines the description/text fields into a single 'description' field.
42+
* @return Builder<EntityTable>
43+
*/
44+
public function visibleForList(): Builder
45+
{
46+
$rawDescriptionField = DB::raw('COALESCE(description, text) as description');
47+
$bookSlugSelect = function (QueryBuilder $query) {
48+
return $query->select('slug')->from('entities as books')
49+
->whereColumn('books.id', '=', 'entities.book_id')
50+
->where('type', '=', 'book');
51+
};
52+
53+
return EntityTable::query()->scopes('visible')
54+
->select(['id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'created_at', 'updated_at', 'draft', 'book_slug' => $bookSlugSelect, $rawDescriptionField])
55+
->leftJoin('entity_container_data', function (JoinClause $join) {
56+
$join->on('entity_container_data.entity_id', '=', 'entities.id')
57+
->on('entity_container_data.entity_type', '=', 'entities.type');
58+
})->leftJoin('entity_page_data', function (JoinClause $join) {
59+
$join->on('entity_page_data.page_id', '=', 'entities.id')
60+
->where('entities.type', '=', 'page');
61+
});
62+
}
63+
3564
/**
3665
* Start a query of visible entities of the given type,
3766
* suitable for listing display.
3867
* @return Builder<Entity>
3968
*/
40-
public function visibleForList(string $entityType): Builder
69+
public function visibleForListForType(string $entityType): Builder
4170
{
4271
$queries = $this->getQueriesForType($entityType);
4372
return $queries->visibleForList();
@@ -48,7 +77,7 @@ public function visibleForList(string $entityType): Builder
4877
* suitable for using the contents of the items.
4978
* @return Builder<Entity>
5079
*/
51-
public function visibleForContent(string $entityType): Builder
80+
public function visibleForContentForType(string $entityType): Builder
5281
{
5382
$queries = $this->getQueriesForType($entityType);
5483
return $queries->visibleForContent();
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
3+
namespace BookStack\Entities\Tools;
4+
5+
use BookStack\Activity\Models\Tag;
6+
use BookStack\Entities\Models\Chapter;
7+
use BookStack\Entities\Models\Entity;
8+
use BookStack\Entities\Models\EntityTable;
9+
use BookStack\Entities\Models\Page;
10+
use BookStack\Entities\Queries\EntityQueries;
11+
use Illuminate\Database\Eloquent\Collection;
12+
13+
class EntityHydrator
14+
{
15+
public function __construct(
16+
protected EntityQueries $entityQueries,
17+
) {
18+
}
19+
20+
/**
21+
* Hydrate the entities of this hydrator to return a list of entities represented
22+
* in their original intended models.
23+
* @param EntityTable[] $entities
24+
* @return Entity[]
25+
*/
26+
public function hydrate(array $entities, bool $loadTags = false, bool $loadParents = false): array
27+
{
28+
$hydrated = [];
29+
30+
foreach ($entities as $entity) {
31+
$data = $entity->getRawOriginal();
32+
$instance = Entity::instanceFromType($entity->type);
33+
34+
if ($instance instanceof Page) {
35+
$data['text'] = $data['description'];
36+
unset($data['description']);
37+
}
38+
39+
$instance = $instance->setRawAttributes($data, true);
40+
$hydrated[] = $instance;
41+
}
42+
43+
if ($loadTags) {
44+
$this->loadTagsIntoModels($hydrated);
45+
}
46+
47+
if ($loadParents) {
48+
$this->loadParentsIntoModels($hydrated);
49+
}
50+
51+
return $hydrated;
52+
}
53+
54+
/**
55+
* @param Entity[] $entities
56+
*/
57+
protected function loadTagsIntoModels(array $entities): void
58+
{
59+
$idsByType = [];
60+
$entityMap = [];
61+
foreach ($entities as $entity) {
62+
if (!isset($idsByType[$entity->type])) {
63+
$idsByType[$entity->type] = [];
64+
}
65+
$idsByType[$entity->type][] = $entity->id;
66+
$entityMap[$entity->type . ':' . $entity->id] = $entity;
67+
}
68+
69+
$query = Tag::query();
70+
foreach ($idsByType as $type => $ids) {
71+
$query->orWhere(function ($query) use ($type, $ids) {
72+
$query->where('entity_type', '=', $type)
73+
->whereIn('entity_id', $ids);
74+
});
75+
}
76+
77+
$tags = empty($idsByType) ? [] : $query->get()->all();
78+
$tagMap = [];
79+
foreach ($tags as $tag) {
80+
$key = $tag->entity_type . ':' . $tag->entity_id;
81+
if (!isset($tagMap[$key])) {
82+
$tagMap[$key] = [];
83+
}
84+
$tagMap[$key][] = $tag;
85+
}
86+
87+
foreach ($entityMap as $key => $entity) {
88+
$entityTags = new Collection($tagMap[$key] ?? []);
89+
$entity->setRelation('tags', $entityTags);
90+
}
91+
}
92+
93+
/**
94+
* @param Entity[] $entities
95+
*/
96+
protected function loadParentsIntoModels(array $entities): void
97+
{
98+
$parentsByType = ['book' => [], 'chapter' => []];
99+
100+
foreach ($entities as $entity) {
101+
if ($entity->getAttribute('book_id') !== null) {
102+
$parentsByType['book'][] = $entity->getAttribute('book_id');
103+
}
104+
if ($entity->getAttribute('chapter_id') !== null) {
105+
$parentsByType['chapter'][] = $entity->getAttribute('chapter_id');
106+
}
107+
}
108+
109+
$parentQuery = $this->entityQueries->visibleForList();
110+
$filtered = count($parentsByType['book']) > 0 || count($parentsByType['chapter']) > 0;
111+
$parentQuery = $parentQuery->where(function ($query) use ($parentsByType) {
112+
foreach ($parentsByType as $type => $ids) {
113+
if (count($ids) > 0) {
114+
$query = $query->orWhere(function ($query) use ($type, $ids) {
115+
$query->where('type', '=', $type)
116+
->whereIn('id', $ids);
117+
});
118+
}
119+
}
120+
});
121+
122+
$parentModels = $filtered ? $parentQuery->get()->all() : [];
123+
$parents = $this->hydrate($parentModels);
124+
$parentMap = [];
125+
foreach ($parents as $parent) {
126+
$parentMap[$parent->type . ':' . $parent->id] = $parent;
127+
}
128+
129+
foreach ($entities as $entity) {
130+
if ($entity instanceof Page || $entity instanceof Chapter) {
131+
$key = 'book:' . $entity->getRawAttribute('book_id');
132+
$entity->setRelation('book', $parentMap[$key] ?? null);
133+
}
134+
if ($entity instanceof Page) {
135+
$key = 'chapter:' . $entity->getRawAttribute('chapter_id');
136+
$entity->setRelation('chapter', $parentMap[$key] ?? null);
137+
}
138+
}
139+
}
140+
}

app/Entities/Tools/MixedEntityListLoader.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents,
5454
$modelMap = [];
5555

5656
foreach ($idsByType as $type => $ids) {
57-
$base = $withContents ? $this->queries->visibleForContent($type) : $this->queries->visibleForList($type);
57+
$base = $withContents ? $this->queries->visibleForContentForType($type) : $this->queries->visibleForListForType($type);
5858
$models = $base->whereIn('id', $ids)
5959
->with($eagerLoadParents ? $this->getRelationsToEagerLoad($type) : [])
6060
->get();

app/Search/SearchApiController.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
<?php
22

3+
declare(strict_types=1);
4+
35
namespace BookStack\Search;
46

57
use BookStack\Api\ApiEntityListFormatter;
68
use BookStack\Entities\Models\Entity;
79
use BookStack\Http\ApiController;
10+
use Illuminate\Http\JsonResponse;
811
use Illuminate\Http\Request;
912

1013
class SearchApiController extends ApiController
@@ -31,11 +34,9 @@ public function __construct(
3134
* between: bookshelf, book, chapter & page.
3235
*
3336
* The paging parameters and response format emulates a standard listing endpoint
34-
* but standard sorting and filtering cannot be done on this endpoint. If a count value
35-
* is provided this will only be taken as a suggestion. The results in the response
36-
* may currently be up to 4x this value.
37+
* but standard sorting and filtering cannot be done on this endpoint.
3738
*/
38-
public function all(Request $request)
39+
public function all(Request $request): JsonResponse
3940
{
4041
$this->validate($request, $this->rules['all']);
4142

app/Search/SearchController.php

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use BookStack\Entities\Tools\SiblingFetcher;
88
use BookStack\Http\Controller;
99
use Illuminate\Http\Request;
10+
use Illuminate\Pagination\LengthAwarePaginator;
1011

1112
class SearchController extends Controller
1213
{
@@ -23,20 +24,21 @@ public function search(Request $request, SearchResultsFormatter $formatter)
2324
{
2425
$searchOpts = SearchOptions::fromRequest($request);
2526
$fullSearchString = $searchOpts->toString();
26-
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
27-
2827
$page = intval($request->get('page', '0')) ?: 1;
29-
$nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page + 1));
3028

3129
$results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
3230
$formatter->format($results['results']->all(), $searchOpts);
31+
$paginator = new LengthAwarePaginator($results['results'], $results['total'], 20, $page);
32+
$paginator->setPath('/search');
33+
$paginator->appends($request->except('page'));
34+
35+
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
3336

3437
return view('search.all', [
3538
'entities' => $results['results'],
3639
'totalResults' => $results['total'],
40+
'paginator' => $paginator,
3741
'searchTerm' => $fullSearchString,
38-
'hasNextPage' => $results['has_more'],
39-
'nextPageLink' => $nextPageLink,
4042
'options' => $searchOpts,
4143
]);
4244
}
@@ -128,7 +130,7 @@ public function searchSuggestions(Request $request)
128130
}
129131

130132
/**
131-
* Search siblings items in the system.
133+
* Search sibling items in the system.
132134
*/
133135
public function searchSiblings(Request $request, SiblingFetcher $siblingFetcher)
134136
{

0 commit comments

Comments
 (0)