mirror of
				https://github.com/BookStackApp/BookStack.git
				synced 2025-11-04 13:31:45 +03:00 
			
		
		
		
	Merge pull request #5854 from BookStackApp/efficient_search
Pagable and efficient search
This commit is contained in:
		@@ -471,4 +471,17 @@ abstract class Entity extends Model implements
 | 
			
		||||
 | 
			
		||||
        return $contentFields;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a new instance for the given entity type.
 | 
			
		||||
     */
 | 
			
		||||
    public static function instanceFromType(string $type): self
 | 
			
		||||
    {
 | 
			
		||||
        return match ($type) {
 | 
			
		||||
            'page' => new Page(),
 | 
			
		||||
            'chapter' => new Chapter(),
 | 
			
		||||
            'book' => new Book(),
 | 
			
		||||
            'bookshelf' => new Bookshelf(),
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										69
									
								
								app/Entities/Models/EntityTable.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								app/Entities/Models/EntityTable.php
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,69 @@
 | 
			
		||||
<?php
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Entities\Models;
 | 
			
		||||
 | 
			
		||||
use BookStack\Activity\Models\Tag;
 | 
			
		||||
use BookStack\Activity\Models\View;
 | 
			
		||||
use BookStack\App\Model;
 | 
			
		||||
use BookStack\Permissions\Models\EntityPermission;
 | 
			
		||||
use BookStack\Permissions\Models\JointPermission;
 | 
			
		||||
use BookStack\Permissions\PermissionApplicator;
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\HasMany;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
 | 
			
		||||
use Illuminate\Database\Eloquent\SoftDeletes;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * This is a simplistic model interpretation of a generic Entity used to query and represent
 | 
			
		||||
 * that database abstractly. Generally, this should rarely be used outside queries.
 | 
			
		||||
 */
 | 
			
		||||
class EntityTable extends Model
 | 
			
		||||
{
 | 
			
		||||
    use SoftDeletes;
 | 
			
		||||
 | 
			
		||||
    protected $table = 'entities';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the entities that are visible to the current user.
 | 
			
		||||
     */
 | 
			
		||||
    public function scopeVisible(Builder $query): Builder
 | 
			
		||||
    {
 | 
			
		||||
        return app()->make(PermissionApplicator::class)->restrictEntityQuery($query);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the entity jointPermissions this is connected to.
 | 
			
		||||
     */
 | 
			
		||||
    public function jointPermissions(): HasMany
 | 
			
		||||
    {
 | 
			
		||||
        return $this->hasMany(JointPermission::class, 'entity_id')
 | 
			
		||||
            ->whereColumn('entity_type', '=', 'entities.type');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the Tags that have been assigned to entities.
 | 
			
		||||
     */
 | 
			
		||||
    public function tags(): HasMany
 | 
			
		||||
    {
 | 
			
		||||
        return $this->hasMany(Tag::class, 'entity_id')
 | 
			
		||||
            ->whereColumn('entity_type', '=', 'entities.type');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the assigned permissions.
 | 
			
		||||
     */
 | 
			
		||||
    public function permissions(): HasMany
 | 
			
		||||
    {
 | 
			
		||||
        return $this->hasMany(EntityPermission::class, 'entity_id')
 | 
			
		||||
            ->whereColumn('entity_type', '=', 'entities.type');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get View objects for this entity.
 | 
			
		||||
     */
 | 
			
		||||
    public function views(): HasMany
 | 
			
		||||
    {
 | 
			
		||||
        return $this->hasMany(View::class, 'viewable_id')
 | 
			
		||||
            ->whereColumn('viewable_type', '=', 'entities.type');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -3,7 +3,11 @@
 | 
			
		||||
namespace BookStack\Entities\Queries;
 | 
			
		||||
 | 
			
		||||
use BookStack\Entities\Models\Entity;
 | 
			
		||||
use BookStack\Entities\Models\EntityTable;
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder;
 | 
			
		||||
use Illuminate\Database\Query\Builder as QueryBuilder;
 | 
			
		||||
use Illuminate\Database\Query\JoinClause;
 | 
			
		||||
use Illuminate\Support\Facades\DB;
 | 
			
		||||
use InvalidArgumentException;
 | 
			
		||||
 | 
			
		||||
class EntityQueries
 | 
			
		||||
@@ -32,12 +36,37 @@ class EntityQueries
 | 
			
		||||
        return $queries->findVisibleById($entityId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Start a query across all entity types.
 | 
			
		||||
     * Combines the description/text fields into a single 'description' field.
 | 
			
		||||
     * @return Builder<EntityTable>
 | 
			
		||||
     */
 | 
			
		||||
    public function visibleForList(): Builder
 | 
			
		||||
    {
 | 
			
		||||
        $rawDescriptionField = DB::raw('COALESCE(description, text) as description');
 | 
			
		||||
        $bookSlugSelect = function (QueryBuilder $query) {
 | 
			
		||||
            return $query->select('slug')->from('entities as books')
 | 
			
		||||
                ->whereColumn('books.id', '=', 'entities.book_id')
 | 
			
		||||
                ->where('type', '=', 'book');
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return EntityTable::query()->scopes('visible')
 | 
			
		||||
            ->select(['id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'created_at', 'updated_at', 'draft', 'book_slug' => $bookSlugSelect, $rawDescriptionField])
 | 
			
		||||
            ->leftJoin('entity_container_data', function (JoinClause $join) {
 | 
			
		||||
                $join->on('entity_container_data.entity_id', '=', 'entities.id')
 | 
			
		||||
                    ->on('entity_container_data.entity_type', '=', 'entities.type');
 | 
			
		||||
            })->leftJoin('entity_page_data', function (JoinClause $join) {
 | 
			
		||||
                $join->on('entity_page_data.page_id', '=', 'entities.id')
 | 
			
		||||
                    ->where('entities.type', '=', 'page');
 | 
			
		||||
            });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Start a query of visible entities of the given type,
 | 
			
		||||
     * suitable for listing display.
 | 
			
		||||
     * @return Builder<Entity>
 | 
			
		||||
     */
 | 
			
		||||
    public function visibleForList(string $entityType): Builder
 | 
			
		||||
    public function visibleForListForType(string $entityType): Builder
 | 
			
		||||
    {
 | 
			
		||||
        $queries = $this->getQueriesForType($entityType);
 | 
			
		||||
        return $queries->visibleForList();
 | 
			
		||||
@@ -48,7 +77,7 @@ class EntityQueries
 | 
			
		||||
     * suitable for using the contents of the items.
 | 
			
		||||
     * @return Builder<Entity>
 | 
			
		||||
     */
 | 
			
		||||
    public function visibleForContent(string $entityType): Builder
 | 
			
		||||
    public function visibleForContentForType(string $entityType): Builder
 | 
			
		||||
    {
 | 
			
		||||
        $queries = $this->getQueriesForType($entityType);
 | 
			
		||||
        return $queries->visibleForContent();
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										140
									
								
								app/Entities/Tools/EntityHydrator.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								app/Entities/Tools/EntityHydrator.php
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,140 @@
 | 
			
		||||
<?php
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Entities\Tools;
 | 
			
		||||
 | 
			
		||||
use BookStack\Activity\Models\Tag;
 | 
			
		||||
use BookStack\Entities\Models\Chapter;
 | 
			
		||||
use BookStack\Entities\Models\Entity;
 | 
			
		||||
use BookStack\Entities\Models\EntityTable;
 | 
			
		||||
use BookStack\Entities\Models\Page;
 | 
			
		||||
use BookStack\Entities\Queries\EntityQueries;
 | 
			
		||||
use Illuminate\Database\Eloquent\Collection;
 | 
			
		||||
 | 
			
		||||
class EntityHydrator
 | 
			
		||||
{
 | 
			
		||||
    public function __construct(
 | 
			
		||||
        protected EntityQueries $entityQueries,
 | 
			
		||||
    ) {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Hydrate the entities of this hydrator to return a list of entities represented
 | 
			
		||||
     * in their original intended models.
 | 
			
		||||
     * @param EntityTable[] $entities
 | 
			
		||||
     * @return Entity[]
 | 
			
		||||
     */
 | 
			
		||||
    public function hydrate(array $entities, bool $loadTags = false, bool $loadParents = false): array
 | 
			
		||||
    {
 | 
			
		||||
        $hydrated = [];
 | 
			
		||||
 | 
			
		||||
        foreach ($entities as $entity) {
 | 
			
		||||
            $data = $entity->getRawOriginal();
 | 
			
		||||
            $instance = Entity::instanceFromType($entity->type);
 | 
			
		||||
 | 
			
		||||
            if ($instance instanceof Page) {
 | 
			
		||||
                $data['text'] = $data['description'];
 | 
			
		||||
                unset($data['description']);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            $instance = $instance->setRawAttributes($data, true);
 | 
			
		||||
            $hydrated[] = $instance;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($loadTags) {
 | 
			
		||||
            $this->loadTagsIntoModels($hydrated);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($loadParents) {
 | 
			
		||||
            $this->loadParentsIntoModels($hydrated);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $hydrated;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param Entity[] $entities
 | 
			
		||||
     */
 | 
			
		||||
    protected function loadTagsIntoModels(array $entities): void
 | 
			
		||||
    {
 | 
			
		||||
        $idsByType = [];
 | 
			
		||||
        $entityMap = [];
 | 
			
		||||
        foreach ($entities as $entity) {
 | 
			
		||||
            if (!isset($idsByType[$entity->type])) {
 | 
			
		||||
                $idsByType[$entity->type] = [];
 | 
			
		||||
            }
 | 
			
		||||
            $idsByType[$entity->type][] = $entity->id;
 | 
			
		||||
            $entityMap[$entity->type . ':' . $entity->id] = $entity;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $query = Tag::query();
 | 
			
		||||
        foreach ($idsByType as $type => $ids) {
 | 
			
		||||
            $query->orWhere(function ($query) use ($type, $ids) {
 | 
			
		||||
                $query->where('entity_type', '=', $type)
 | 
			
		||||
                    ->whereIn('entity_id', $ids);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $tags = empty($idsByType) ? [] : $query->get()->all();
 | 
			
		||||
        $tagMap = [];
 | 
			
		||||
        foreach ($tags as $tag) {
 | 
			
		||||
            $key = $tag->entity_type . ':' . $tag->entity_id;
 | 
			
		||||
            if (!isset($tagMap[$key])) {
 | 
			
		||||
                $tagMap[$key] = [];
 | 
			
		||||
            }
 | 
			
		||||
            $tagMap[$key][] = $tag;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        foreach ($entityMap as $key => $entity) {
 | 
			
		||||
            $entityTags = new Collection($tagMap[$key] ?? []);
 | 
			
		||||
            $entity->setRelation('tags', $entityTags);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param Entity[] $entities
 | 
			
		||||
     */
 | 
			
		||||
    protected function loadParentsIntoModels(array $entities): void
 | 
			
		||||
    {
 | 
			
		||||
        $parentsByType = ['book' => [], 'chapter' => []];
 | 
			
		||||
 | 
			
		||||
        foreach ($entities as $entity) {
 | 
			
		||||
            if ($entity->getAttribute('book_id') !== null) {
 | 
			
		||||
                $parentsByType['book'][] = $entity->getAttribute('book_id');
 | 
			
		||||
            }
 | 
			
		||||
            if ($entity->getAttribute('chapter_id') !== null) {
 | 
			
		||||
                $parentsByType['chapter'][] = $entity->getAttribute('chapter_id');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $parentQuery = $this->entityQueries->visibleForList();
 | 
			
		||||
        $filtered = count($parentsByType['book']) > 0 || count($parentsByType['chapter']) > 0;
 | 
			
		||||
        $parentQuery = $parentQuery->where(function ($query) use ($parentsByType) {
 | 
			
		||||
            foreach ($parentsByType as $type => $ids) {
 | 
			
		||||
                if (count($ids) > 0) {
 | 
			
		||||
                    $query = $query->orWhere(function ($query) use ($type, $ids) {
 | 
			
		||||
                        $query->where('type', '=', $type)
 | 
			
		||||
                            ->whereIn('id', $ids);
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        $parentModels = $filtered ? $parentQuery->get()->all() : [];
 | 
			
		||||
        $parents = $this->hydrate($parentModels);
 | 
			
		||||
        $parentMap = [];
 | 
			
		||||
        foreach ($parents as $parent) {
 | 
			
		||||
            $parentMap[$parent->type . ':' . $parent->id] = $parent;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        foreach ($entities as $entity) {
 | 
			
		||||
            if ($entity instanceof Page || $entity instanceof Chapter) {
 | 
			
		||||
                $key = 'book:' . $entity->getRawAttribute('book_id');
 | 
			
		||||
                $entity->setRelation('book', $parentMap[$key] ?? null);
 | 
			
		||||
            }
 | 
			
		||||
            if ($entity instanceof Page) {
 | 
			
		||||
                $key = 'chapter:' . $entity->getRawAttribute('chapter_id');
 | 
			
		||||
                $entity->setRelation('chapter', $parentMap[$key] ?? null);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -54,7 +54,7 @@ class MixedEntityListLoader
 | 
			
		||||
        $modelMap = [];
 | 
			
		||||
 | 
			
		||||
        foreach ($idsByType as $type => $ids) {
 | 
			
		||||
            $base = $withContents ? $this->queries->visibleForContent($type) : $this->queries->visibleForList($type);
 | 
			
		||||
            $base = $withContents ? $this->queries->visibleForContentForType($type) : $this->queries->visibleForListForType($type);
 | 
			
		||||
            $models = $base->whereIn('id', $ids)
 | 
			
		||||
                ->with($eagerLoadParents ? $this->getRelationsToEagerLoad($type) : [])
 | 
			
		||||
                ->get();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,13 @@
 | 
			
		||||
<?php
 | 
			
		||||
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Search;
 | 
			
		||||
 | 
			
		||||
use BookStack\Api\ApiEntityListFormatter;
 | 
			
		||||
use BookStack\Entities\Models\Entity;
 | 
			
		||||
use BookStack\Http\ApiController;
 | 
			
		||||
use Illuminate\Http\JsonResponse;
 | 
			
		||||
use Illuminate\Http\Request;
 | 
			
		||||
 | 
			
		||||
class SearchApiController extends ApiController
 | 
			
		||||
@@ -31,11 +34,9 @@ class SearchApiController extends ApiController
 | 
			
		||||
     * between: bookshelf, book, chapter & page.
 | 
			
		||||
     *
 | 
			
		||||
     * The paging parameters and response format emulates a standard listing endpoint
 | 
			
		||||
     * but standard sorting and filtering cannot be done on this endpoint. If a count value
 | 
			
		||||
     * is provided this will only be taken as a suggestion. The results in the response
 | 
			
		||||
     * may currently be up to 4x this value.
 | 
			
		||||
     * but standard sorting and filtering cannot be done on this endpoint.
 | 
			
		||||
     */
 | 
			
		||||
    public function all(Request $request)
 | 
			
		||||
    public function all(Request $request): JsonResponse
 | 
			
		||||
    {
 | 
			
		||||
        $this->validate($request, $this->rules['all']);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ use BookStack\Entities\Queries\QueryPopular;
 | 
			
		||||
use BookStack\Entities\Tools\SiblingFetcher;
 | 
			
		||||
use BookStack\Http\Controller;
 | 
			
		||||
use Illuminate\Http\Request;
 | 
			
		||||
use Illuminate\Pagination\LengthAwarePaginator;
 | 
			
		||||
 | 
			
		||||
class SearchController extends Controller
 | 
			
		||||
{
 | 
			
		||||
@@ -23,20 +24,21 @@ class SearchController extends Controller
 | 
			
		||||
    {
 | 
			
		||||
        $searchOpts = SearchOptions::fromRequest($request);
 | 
			
		||||
        $fullSearchString = $searchOpts->toString();
 | 
			
		||||
        $this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
 | 
			
		||||
 | 
			
		||||
        $page = intval($request->get('page', '0')) ?: 1;
 | 
			
		||||
        $nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page + 1));
 | 
			
		||||
 | 
			
		||||
        $results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
 | 
			
		||||
        $formatter->format($results['results']->all(), $searchOpts);
 | 
			
		||||
        $paginator = new LengthAwarePaginator($results['results'], $results['total'], 20, $page);
 | 
			
		||||
        $paginator->setPath('/search');
 | 
			
		||||
        $paginator->appends($request->except('page'));
 | 
			
		||||
 | 
			
		||||
        $this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
 | 
			
		||||
 | 
			
		||||
        return view('search.all', [
 | 
			
		||||
            'entities'     => $results['results'],
 | 
			
		||||
            'totalResults' => $results['total'],
 | 
			
		||||
            'paginator'    => $paginator,
 | 
			
		||||
            'searchTerm'   => $fullSearchString,
 | 
			
		||||
            'hasNextPage'  => $results['has_more'],
 | 
			
		||||
            'nextPageLink' => $nextPageLink,
 | 
			
		||||
            'options'      => $searchOpts,
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
@@ -128,7 +130,7 @@ class SearchController extends Controller
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Search siblings items in the system.
 | 
			
		||||
     * Search sibling items in the system.
 | 
			
		||||
     */
 | 
			
		||||
    public function searchSiblings(Request $request, SiblingFetcher $siblingFetcher)
 | 
			
		||||
    {
 | 
			
		||||
 
 | 
			
		||||
@@ -4,16 +4,15 @@ namespace BookStack\Search;
 | 
			
		||||
 | 
			
		||||
use BookStack\Entities\EntityProvider;
 | 
			
		||||
use BookStack\Entities\Models\Entity;
 | 
			
		||||
use BookStack\Entities\Models\Page;
 | 
			
		||||
use BookStack\Entities\Queries\EntityQueries;
 | 
			
		||||
use BookStack\Entities\Tools\EntityHydrator;
 | 
			
		||||
use BookStack\Permissions\PermissionApplicator;
 | 
			
		||||
use BookStack\Search\Options\TagSearchOption;
 | 
			
		||||
use BookStack\Users\Models\User;
 | 
			
		||||
use Illuminate\Database\Connection;
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
 | 
			
		||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
 | 
			
		||||
use Illuminate\Database\Query\Builder;
 | 
			
		||||
use Illuminate\Database\Query\JoinClause;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
use Illuminate\Support\Facades\DB;
 | 
			
		||||
use Illuminate\Support\Str;
 | 
			
		||||
@@ -22,7 +21,7 @@ use WeakMap;
 | 
			
		||||
class SearchRunner
 | 
			
		||||
{
 | 
			
		||||
    /**
 | 
			
		||||
     * Retain a cache of score adjusted terms for specific search options.
 | 
			
		||||
     * Retain a cache of score-adjusted terms for specific search options.
 | 
			
		||||
     */
 | 
			
		||||
    protected WeakMap $termAdjustmentCache;
 | 
			
		||||
 | 
			
		||||
@@ -30,16 +29,15 @@ class SearchRunner
 | 
			
		||||
        protected EntityProvider $entityProvider,
 | 
			
		||||
        protected PermissionApplicator $permissions,
 | 
			
		||||
        protected EntityQueries $entityQueries,
 | 
			
		||||
        protected EntityHydrator $entityHydrator,
 | 
			
		||||
    ) {
 | 
			
		||||
        $this->termAdjustmentCache = new WeakMap();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Search all entities in the system.
 | 
			
		||||
     * The provided count is for each entity to search,
 | 
			
		||||
     * Total returned could be larger and not guaranteed.
 | 
			
		||||
     *
 | 
			
		||||
     * @return array{total: int, count: int, has_more: bool, results: Collection<Entity>}
 | 
			
		||||
     * @return array{total: int, results: Collection<Entity>}
 | 
			
		||||
     */
 | 
			
		||||
    public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20): array
 | 
			
		||||
    {
 | 
			
		||||
@@ -53,32 +51,13 @@ class SearchRunner
 | 
			
		||||
            $entityTypesToSearch = explode('|', $filterMap['type']);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $results = collect();
 | 
			
		||||
        $total = 0;
 | 
			
		||||
        $hasMore = false;
 | 
			
		||||
 | 
			
		||||
        foreach ($entityTypesToSearch as $entityType) {
 | 
			
		||||
            if (!in_array($entityType, $entityTypes)) {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            $searchQuery = $this->buildQuery($searchOpts, $entityType);
 | 
			
		||||
            $entityTotal = $searchQuery->count();
 | 
			
		||||
            $searchResults = $this->getPageOfDataFromQuery($searchQuery, $entityType, $page, $count);
 | 
			
		||||
 | 
			
		||||
            if ($entityTotal > ($page * $count)) {
 | 
			
		||||
                $hasMore = true;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            $total += $entityTotal;
 | 
			
		||||
            $results = $results->merge($searchResults);
 | 
			
		||||
        }
 | 
			
		||||
        $searchQuery = $this->buildQuery($searchOpts, $entityTypesToSearch);
 | 
			
		||||
        $total = $searchQuery->count();
 | 
			
		||||
        $results = $this->getPageOfDataFromQuery($searchQuery, $page, $count);
 | 
			
		||||
 | 
			
		||||
        return [
 | 
			
		||||
            'total'    => $total,
 | 
			
		||||
            'count'    => count($results),
 | 
			
		||||
            'has_more' => $hasMore,
 | 
			
		||||
            'results'  => $results->sortByDesc('score')->values(),
 | 
			
		||||
            'results'  => $results->values(),
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -92,17 +71,10 @@ class SearchRunner
 | 
			
		||||
        $filterMap = $opts->filters->toValueMap();
 | 
			
		||||
        $entityTypesToSearch = isset($filterMap['type']) ? explode('|', $filterMap['type']) : $entityTypes;
 | 
			
		||||
 | 
			
		||||
        $results = collect();
 | 
			
		||||
        foreach ($entityTypesToSearch as $entityType) {
 | 
			
		||||
            if (!in_array($entityType, $entityTypes)) {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
        $filteredTypes = array_intersect($entityTypesToSearch, $entityTypes);
 | 
			
		||||
        $query = $this->buildQuery($opts, $filteredTypes)->where('book_id', '=', $bookId);
 | 
			
		||||
 | 
			
		||||
            $search = $this->buildQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
 | 
			
		||||
            $results = $results->merge($search);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $results->sortByDesc('score')->take(20);
 | 
			
		||||
        return $this->getPageOfDataFromQuery($query, 1, 20)->sortByDesc('score');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@@ -111,54 +83,45 @@ class SearchRunner
 | 
			
		||||
    public function searchChapter(int $chapterId, string $searchString): Collection
 | 
			
		||||
    {
 | 
			
		||||
        $opts = SearchOptions::fromString($searchString);
 | 
			
		||||
        $pages = $this->buildQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
 | 
			
		||||
        $query = $this->buildQuery($opts, ['page'])->where('chapter_id', '=', $chapterId);
 | 
			
		||||
 | 
			
		||||
        return $pages->sortByDesc('score');
 | 
			
		||||
        return $this->getPageOfDataFromQuery($query, 1, 20)->sortByDesc('score');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a page of result data from the given query based on the provided page parameters.
 | 
			
		||||
     */
 | 
			
		||||
    protected function getPageOfDataFromQuery(EloquentBuilder $query, string $entityType, int $page = 1, int $count = 20): EloquentCollection
 | 
			
		||||
    protected function getPageOfDataFromQuery(EloquentBuilder $query, int $page, int $count): Collection
 | 
			
		||||
    {
 | 
			
		||||
        $relations = ['tags'];
 | 
			
		||||
 | 
			
		||||
        if ($entityType === 'page' || $entityType === 'chapter') {
 | 
			
		||||
            $relations['book'] = function (BelongsTo $query) {
 | 
			
		||||
                $query->scopes('visible');
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($entityType === 'page') {
 | 
			
		||||
            $relations['chapter'] = function (BelongsTo $query) {
 | 
			
		||||
                $query->scopes('visible');
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $query->clone()
 | 
			
		||||
            ->with(array_filter($relations))
 | 
			
		||||
        $entities = $query->clone()
 | 
			
		||||
            ->skip(($page - 1) * $count)
 | 
			
		||||
            ->take($count)
 | 
			
		||||
            ->get();
 | 
			
		||||
 | 
			
		||||
        $hydrated = $this->entityHydrator->hydrate($entities->all(), true, true);
 | 
			
		||||
 | 
			
		||||
        return collect($hydrated);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a search query for an entity.
 | 
			
		||||
     * @param string[] $entityTypes
 | 
			
		||||
     */
 | 
			
		||||
    protected function buildQuery(SearchOptions $searchOpts, string $entityType): EloquentBuilder
 | 
			
		||||
    protected function buildQuery(SearchOptions $searchOpts, array $entityTypes): EloquentBuilder
 | 
			
		||||
    {
 | 
			
		||||
        $entityModelInstance = $this->entityProvider->get($entityType);
 | 
			
		||||
        $entityQuery = $this->entityQueries->visibleForList($entityType);
 | 
			
		||||
        $entityQuery = $this->entityQueries->visibleForList()
 | 
			
		||||
            ->whereIn('type', $entityTypes);
 | 
			
		||||
 | 
			
		||||
        // Handle normal search terms
 | 
			
		||||
        $this->applyTermSearch($entityQuery, $searchOpts, $entityType);
 | 
			
		||||
        $this->applyTermSearch($entityQuery, $searchOpts, $entityTypes);
 | 
			
		||||
 | 
			
		||||
        // Handle exact term matching
 | 
			
		||||
        foreach ($searchOpts->exacts->all() as $exact) {
 | 
			
		||||
            $filter = function (EloquentBuilder $query) use ($exact, $entityModelInstance) {
 | 
			
		||||
            $filter = function (EloquentBuilder $query) use ($exact) {
 | 
			
		||||
                $inputTerm = str_replace('\\', '\\\\', $exact->value);
 | 
			
		||||
                $query->where('name', 'like', '%' . $inputTerm . '%')
 | 
			
		||||
                    ->orWhere($entityModelInstance->textField, 'like', '%' . $inputTerm . '%');
 | 
			
		||||
                    ->orWhere('description', 'like', '%' . $inputTerm . '%')
 | 
			
		||||
                    ->orWhere('text', 'like', '%' . $inputTerm . '%');
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            $exact->negated ? $entityQuery->whereNot($filter) : $entityQuery->where($filter);
 | 
			
		||||
@@ -173,7 +136,7 @@ class SearchRunner
 | 
			
		||||
        foreach ($searchOpts->filters->all() as $filterOption) {
 | 
			
		||||
            $functionName = Str::camel('filter_' . $filterOption->getKey());
 | 
			
		||||
            if (method_exists($this, $functionName)) {
 | 
			
		||||
                $this->$functionName($entityQuery, $entityModelInstance, $filterOption->value, $filterOption->negated);
 | 
			
		||||
                $this->$functionName($entityQuery, $filterOption->value, $filterOption->negated);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -183,7 +146,7 @@ class SearchRunner
 | 
			
		||||
    /**
 | 
			
		||||
     * For the given search query, apply the queries for handling the regular search terms.
 | 
			
		||||
     */
 | 
			
		||||
    protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, string $entityType): void
 | 
			
		||||
    protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, array $entityTypes): void
 | 
			
		||||
    {
 | 
			
		||||
        $terms = $options->searches->toValueArray();
 | 
			
		||||
        if (count($terms) === 0) {
 | 
			
		||||
@@ -200,8 +163,6 @@ class SearchRunner
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        $subQuery->addBinding($scoreSelect['bindings'], 'select');
 | 
			
		||||
 | 
			
		||||
        $subQuery->where('entity_type', '=', $entityType);
 | 
			
		||||
        $subQuery->where(function (Builder $query) use ($terms) {
 | 
			
		||||
            foreach ($terms as $inputTerm) {
 | 
			
		||||
                $escapedTerm = str_replace('\\', '\\\\', $inputTerm);
 | 
			
		||||
@@ -210,7 +171,10 @@ class SearchRunner
 | 
			
		||||
        });
 | 
			
		||||
        $subQuery->groupBy('entity_type', 'entity_id');
 | 
			
		||||
 | 
			
		||||
        $entityQuery->joinSub($subQuery, 's', 'id', '=', 'entity_id');
 | 
			
		||||
        $entityQuery->joinSub($subQuery, 's', function (JoinClause $join) {
 | 
			
		||||
            $join->on('s.entity_id', '=', 'entities.id')
 | 
			
		||||
                ->on('s.entity_type', '=', 'entities.type');
 | 
			
		||||
        });
 | 
			
		||||
        $entityQuery->addSelect('s.score');
 | 
			
		||||
        $entityQuery->orderBy('score', 'desc');
 | 
			
		||||
    }
 | 
			
		||||
@@ -338,7 +302,7 @@ class SearchRunner
 | 
			
		||||
        $option->negated ? $query->whereDoesntHave('tags', $filter) : $query->whereHas('tags', $filter);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string $column, string $operator, mixed $value): void
 | 
			
		||||
    protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string|callable $column, string|null $operator, mixed $value): void
 | 
			
		||||
    {
 | 
			
		||||
        if ($negated) {
 | 
			
		||||
            $query->whereNot($column, $operator, $value);
 | 
			
		||||
@@ -350,31 +314,31 @@ class SearchRunner
 | 
			
		||||
    /**
 | 
			
		||||
     * Custom entity search filters.
 | 
			
		||||
     */
 | 
			
		||||
    protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
 | 
			
		||||
    protected function filterUpdatedAfter(EloquentBuilder $query, string $input, bool $negated): void
 | 
			
		||||
    {
 | 
			
		||||
        $date = date_create($input);
 | 
			
		||||
        $this->applyNegatableWhere($query, $negated, 'updated_at', '>=', $date);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
 | 
			
		||||
    protected function filterUpdatedBefore(EloquentBuilder $query, string $input, bool $negated): void
 | 
			
		||||
    {
 | 
			
		||||
        $date = date_create($input);
 | 
			
		||||
        $this->applyNegatableWhere($query, $negated, 'updated_at', '<', $date);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
 | 
			
		||||
    protected function filterCreatedAfter(EloquentBuilder $query, string $input, bool $negated): void
 | 
			
		||||
    {
 | 
			
		||||
        $date = date_create($input);
 | 
			
		||||
        $this->applyNegatableWhere($query, $negated, 'created_at', '>=', $date);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated)
 | 
			
		||||
    protected function filterCreatedBefore(EloquentBuilder $query, string $input, bool $negated)
 | 
			
		||||
    {
 | 
			
		||||
        $date = date_create($input);
 | 
			
		||||
        $this->applyNegatableWhere($query, $negated, 'created_at', '<', $date);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function filterCreatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
 | 
			
		||||
    protected function filterCreatedBy(EloquentBuilder $query, string $input, bool $negated)
 | 
			
		||||
    {
 | 
			
		||||
        $userSlug = $input === 'me' ? user()->slug : trim($input);
 | 
			
		||||
        $user = User::query()->where('slug', '=', $userSlug)->first(['id']);
 | 
			
		||||
@@ -383,7 +347,7 @@ class SearchRunner
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
 | 
			
		||||
    protected function filterUpdatedBy(EloquentBuilder $query, string $input, bool $negated)
 | 
			
		||||
    {
 | 
			
		||||
        $userSlug = $input === 'me' ? user()->slug : trim($input);
 | 
			
		||||
        $user = User::query()->where('slug', '=', $userSlug)->first(['id']);
 | 
			
		||||
@@ -392,7 +356,7 @@ class SearchRunner
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function filterOwnedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
 | 
			
		||||
    protected function filterOwnedBy(EloquentBuilder $query, string $input, bool $negated)
 | 
			
		||||
    {
 | 
			
		||||
        $userSlug = $input === 'me' ? user()->slug : trim($input);
 | 
			
		||||
        $user = User::query()->where('slug', '=', $userSlug)->first(['id']);
 | 
			
		||||
@@ -401,27 +365,30 @@ class SearchRunner
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function filterInName(EloquentBuilder $query, Entity $model, string $input, bool $negated)
 | 
			
		||||
    protected function filterInName(EloquentBuilder $query, string $input, bool $negated)
 | 
			
		||||
    {
 | 
			
		||||
        $this->applyNegatableWhere($query, $negated, 'name', 'like', '%' . $input . '%');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function filterInTitle(EloquentBuilder $query, Entity $model, string $input, bool $negated)
 | 
			
		||||
    protected function filterInTitle(EloquentBuilder $query, string $input, bool $negated)
 | 
			
		||||
    {
 | 
			
		||||
        $this->filterInName($query, $model, $input, $negated);
 | 
			
		||||
        $this->filterInName($query, $input, $negated);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function filterInBody(EloquentBuilder $query, Entity $model, string $input, bool $negated)
 | 
			
		||||
    protected function filterInBody(EloquentBuilder $query, string $input, bool $negated)
 | 
			
		||||
    {
 | 
			
		||||
        $this->applyNegatableWhere($query, $negated, $model->textField, 'like', '%' . $input . '%');
 | 
			
		||||
        $this->applyNegatableWhere($query, $negated, function (EloquentBuilder $query) use ($input) {
 | 
			
		||||
            $query->where('description', 'like', '%' . $input . '%')
 | 
			
		||||
                ->orWhere('text', 'like', '%' . $input . '%');
 | 
			
		||||
        }, null, null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function filterIsRestricted(EloquentBuilder $query, Entity $model, string $input, bool $negated)
 | 
			
		||||
    protected function filterIsRestricted(EloquentBuilder $query, string $input, bool $negated)
 | 
			
		||||
    {
 | 
			
		||||
        $negated ? $query->whereDoesntHave('permissions') : $query->whereHas('permissions');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function filterViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated)
 | 
			
		||||
    protected function filterViewedByMe(EloquentBuilder $query, string $input, bool $negated)
 | 
			
		||||
    {
 | 
			
		||||
        $filter = function ($query) {
 | 
			
		||||
            $query->where('user_id', '=', user()->id);
 | 
			
		||||
@@ -430,7 +397,7 @@ class SearchRunner
 | 
			
		||||
        $negated ? $query->whereDoesntHave('views', $filter) : $query->whereHas('views', $filter);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated)
 | 
			
		||||
    protected function filterNotViewedByMe(EloquentBuilder $query, string $input, bool $negated)
 | 
			
		||||
    {
 | 
			
		||||
        $filter = function ($query) {
 | 
			
		||||
            $query->where('user_id', '=', user()->id);
 | 
			
		||||
@@ -439,31 +406,30 @@ class SearchRunner
 | 
			
		||||
        $negated ? $query->whereHas('views', $filter) : $query->whereDoesntHave('views', $filter);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function filterIsTemplate(EloquentBuilder $query, Entity $model, string $input, bool $negated)
 | 
			
		||||
    protected function filterIsTemplate(EloquentBuilder $query, string $input, bool $negated)
 | 
			
		||||
    {
 | 
			
		||||
        if ($model instanceof Page) {
 | 
			
		||||
            $this->applyNegatableWhere($query, $negated, 'template', '=', true);
 | 
			
		||||
        }
 | 
			
		||||
        $this->applyNegatableWhere($query, $negated, 'template', '=', true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function filterSortBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
 | 
			
		||||
    protected function filterSortBy(EloquentBuilder $query, string $input, bool $negated)
 | 
			
		||||
    {
 | 
			
		||||
        $functionName = Str::camel('sort_by_' . $input);
 | 
			
		||||
        if (method_exists($this, $functionName)) {
 | 
			
		||||
            $this->$functionName($query, $model, $negated);
 | 
			
		||||
            $this->$functionName($query, $negated);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sorting filter options.
 | 
			
		||||
     */
 | 
			
		||||
    protected function sortByLastCommented(EloquentBuilder $query, Entity $model, bool $negated)
 | 
			
		||||
    protected function sortByLastCommented(EloquentBuilder $query, bool $negated)
 | 
			
		||||
    {
 | 
			
		||||
        $commentsTable = DB::getTablePrefix() . 'comments';
 | 
			
		||||
        $morphClass = str_replace('\\', '\\\\', $model->getMorphClass());
 | 
			
		||||
        $commentQuery = DB::raw('(SELECT c1.commentable_id, c1.commentable_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.commentable_id = c2.commentable_id AND c1.commentable_type = c2.commentable_type AND c1.created_at < c2.created_at) WHERE c1.commentable_type = \'' . $morphClass . '\' AND c2.created_at IS NULL) as comments');
 | 
			
		||||
        $commentQuery = DB::raw('(SELECT c1.commentable_id, c1.commentable_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.commentable_id = c2.commentable_id AND c1.commentable_type = c2.commentable_type AND c1.created_at < c2.created_at) WHERE c2.created_at IS NULL) as comments');
 | 
			
		||||
 | 
			
		||||
        $query->join($commentQuery, $model->getTable() . '.id', '=', DB::raw('comments.commentable_id'))
 | 
			
		||||
            ->orderBy('last_commented', $negated ? 'asc' : 'desc');
 | 
			
		||||
        $query->join($commentQuery, function (JoinClause $join) {
 | 
			
		||||
            $join->on('entities.id', '=', 'comments.commentable_id')
 | 
			
		||||
                ->on('entities.type', '=', 'comments.commentable_type');
 | 
			
		||||
        })->orderBy('last_commented', $negated ? 'asc' : 'desc');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -87,11 +87,7 @@
 | 
			
		||||
                        @include('entities.list', ['entities' => $entities, 'showPath' => true, 'showTags' => true])
 | 
			
		||||
                    </div>
 | 
			
		||||
 | 
			
		||||
                    @if($hasNextPage)
 | 
			
		||||
                        <div class="text-right mt-m">
 | 
			
		||||
                            <a href="{{ $nextPageLink }}" class="button outline">{{ trans('entities.search_more') }}</a>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    @endif
 | 
			
		||||
                    {{ $paginator->render() }}
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -113,6 +113,7 @@ class SearchApiTest extends TestCase
 | 
			
		||||
        $this->permissions->disableEntityInheritedPermissions($book);
 | 
			
		||||
 | 
			
		||||
        $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue');
 | 
			
		||||
        $resp->assertOk();
 | 
			
		||||
        $resp->assertJsonPath('data.0.id', $page->id);
 | 
			
		||||
        $resp->assertJsonMissingPath('data.0.book.name');
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -27,6 +27,12 @@ class EntitySearchTest extends TestCase
 | 
			
		||||
        $search->assertSeeText($shelf->name, true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function test_search_shows_pagination()
 | 
			
		||||
    {
 | 
			
		||||
        $search = $this->asEditor()->get('/search?term=a');
 | 
			
		||||
        $this->withHtml($search)->assertLinkExists('/search?term=a&page=2', '2');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function test_invalid_page_search()
 | 
			
		||||
    {
 | 
			
		||||
        $resp = $this->asEditor()->get('/search?term=' . urlencode('<p>test</p>'));
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user