2021-06-26 23:23:15 +08:00
< ? php
namespace BookStack\Entities\Tools ;
2018-09-25 19:30:50 +08:00
use BookStack\Auth\Permissions\PermissionService ;
2021-03-11 06:51:18 +08:00
use BookStack\Auth\User ;
2020-11-22 08:17:45 +08:00
use BookStack\Entities\EntityProvider ;
use BookStack\Entities\Models\Entity ;
2017-03-19 20:48:44 +08:00
use Illuminate\Database\Connection ;
2018-09-26 01:00:40 +08:00
use Illuminate\Database\Eloquent\Builder as EloquentBuilder ;
2017-03-27 18:57:33 +08:00
use Illuminate\Database\Query\Builder ;
2017-03-19 20:48:44 +08:00
use Illuminate\Database\Query\JoinClause ;
2017-04-16 02:16:07 +08:00
use Illuminate\Support\Collection ;
2019-09-14 06:58:40 +08:00
use Illuminate\Support\Str ;
2017-03-19 20:48:44 +08:00
2020-11-22 08:17:45 +08:00
class SearchRunner
2017-03-19 20:48:44 +08:00
{
2018-09-26 01:00:40 +08:00
/**
* @ var EntityProvider
*/
protected $entityProvider ;
/**
* @ var Connection
*/
2017-03-19 20:48:44 +08:00
protected $db ;
2018-09-23 19:34:30 +08:00
/**
2018-09-26 01:00:40 +08:00
* @ var PermissionService
2018-09-23 19:34:30 +08:00
*/
2018-09-26 01:00:40 +08:00
protected $permissionService ;
2017-03-28 01:05:34 +08:00
/**
2021-06-26 23:23:15 +08:00
* Acceptable operators to be used in a query .
*
2017-03-28 01:05:34 +08:00
* @ var array
*/
protected $queryOperators = [ '<=' , '>=' , '=' , '<' , '>' , 'like' , '!=' ];
2020-11-22 08:17:45 +08:00
public function __construct ( EntityProvider $entityProvider , Connection $db , PermissionService $permissionService )
2017-03-19 20:48:44 +08:00
{
2018-09-26 01:00:40 +08:00
$this -> entityProvider = $entityProvider ;
2017-03-19 20:48:44 +08:00
$this -> db = $db ;
2017-03-27 02:24:57 +08:00
$this -> permissionService = $permissionService ;
2017-03-19 20:48:44 +08:00
}
2017-03-27 18:57:33 +08:00
/**
* Search all entities in the system .
2020-06-27 20:29:00 +08:00
* The provided count is for each entity to search ,
* Total returned could can be larger and not guaranteed .
2017-03-27 18:57:33 +08:00
*/
2020-06-27 20:29:00 +08:00
public function searchEntities ( SearchOptions $searchOpts , string $entityType = 'all' , int $page = 1 , int $count = 20 , string $action = 'view' ) : array
2017-03-19 20:48:44 +08:00
{
2018-09-26 01:00:40 +08:00
$entityTypes = array_keys ( $this -> entityProvider -> all ());
2017-04-10 03:59:57 +08:00
$entityTypesToSearch = $entityTypes ;
if ( $entityType !== 'all' ) {
$entityTypesToSearch = $entityType ;
2021-06-26 23:23:15 +08:00
} elseif ( isset ( $searchOpts -> filters [ 'type' ])) {
2020-06-27 20:29:00 +08:00
$entityTypesToSearch = explode ( '|' , $searchOpts -> filters [ 'type' ]);
2017-04-10 03:59:57 +08:00
}
2018-03-25 02:46:31 +08:00
$results = collect ();
2017-04-15 22:04:30 +08:00
$total = 0 ;
2018-03-25 03:04:18 +08:00
$hasMore = false ;
2017-04-15 22:04:30 +08:00
2017-04-10 03:59:57 +08:00
foreach ( $entityTypesToSearch as $entityType ) {
2018-01-29 00:58:52 +08:00
if ( ! in_array ( $entityType , $entityTypes )) {
continue ;
}
2020-06-27 20:29:00 +08:00
$search = $this -> searchEntityTable ( $searchOpts , $entityType , $page , $count , $action );
$entityTotal = $this -> searchEntityTable ( $searchOpts , $entityType , $page , $count , $action , true );
2018-03-25 03:04:18 +08:00
if ( $entityTotal > $page * $count ) {
$hasMore = true ;
}
$total += $entityTotal ;
2017-04-10 03:59:57 +08:00
$results = $results -> merge ( $search );
}
2017-03-27 02:24:57 +08:00
2017-04-15 22:04:30 +08:00
return [
2021-06-26 23:23:15 +08:00
'total' => $total ,
'count' => count ( $results ),
2018-03-25 03:04:18 +08:00
'has_more' => $hasMore ,
2021-06-26 23:23:15 +08:00
'results' => $results -> sortByDesc ( 'score' ) -> values (),
2017-04-15 22:04:30 +08:00
];
2017-03-27 02:24:57 +08:00
}
2017-04-16 02:16:07 +08:00
/**
2021-06-26 23:23:15 +08:00
* Search a book for entities .
2017-04-16 02:16:07 +08:00
*/
2020-06-27 20:29:00 +08:00
public function searchBook ( int $bookId , string $searchString ) : Collection
2017-04-16 02:16:07 +08:00
{
2020-06-27 20:29:00 +08:00
$opts = SearchOptions :: fromString ( $searchString );
2017-04-16 02:31:11 +08:00
$entityTypes = [ 'page' , 'chapter' ];
2020-06-27 20:29:00 +08:00
$entityTypesToSearch = isset ( $opts -> filters [ 'type' ]) ? explode ( '|' , $opts -> filters [ 'type' ]) : $entityTypes ;
2017-04-16 02:31:11 +08:00
2017-04-16 02:16:07 +08:00
$results = collect ();
2017-04-16 02:31:11 +08:00
foreach ( $entityTypesToSearch as $entityType ) {
2018-01-29 00:58:52 +08:00
if ( ! in_array ( $entityType , $entityTypes )) {
continue ;
}
2020-06-27 20:29:00 +08:00
$search = $this -> buildEntitySearchQuery ( $opts , $entityType ) -> where ( 'book_id' , '=' , $bookId ) -> take ( 20 ) -> get ();
2017-04-16 02:31:11 +08:00
$results = $results -> merge ( $search );
}
2020-11-22 08:17:45 +08:00
2017-04-16 02:31:11 +08:00
return $results -> sortByDesc ( 'score' ) -> take ( 20 );
2017-04-16 02:16:07 +08:00
}
/**
2021-06-26 23:23:15 +08:00
* Search a chapter for entities .
2017-04-16 02:16:07 +08:00
*/
2020-06-27 20:29:00 +08:00
public function searchChapter ( int $chapterId , string $searchString ) : Collection
2017-04-16 02:16:07 +08:00
{
2020-06-27 20:29:00 +08:00
$opts = SearchOptions :: fromString ( $searchString );
$pages = $this -> buildEntitySearchQuery ( $opts , 'page' ) -> where ( 'chapter_id' , '=' , $chapterId ) -> take ( 20 ) -> get ();
2021-06-26 23:23:15 +08:00
2017-04-16 02:16:07 +08:00
return $pages -> sortByDesc ( 'score' );
}
2017-03-27 18:57:33 +08:00
/**
* Search across a particular entity type .
2020-06-27 20:29:00 +08:00
* Setting getCount = true will return the total
* matching instead of the items themselves .
2021-06-26 23:23:15 +08:00
*
2017-04-15 22:04:30 +08:00
* @ return \Illuminate\Database\Eloquent\Collection | int | static []
2017-03-27 18:57:33 +08:00
*/
2020-11-22 08:17:45 +08:00
protected function searchEntityTable ( SearchOptions $searchOpts , string $entityType = 'page' , int $page = 1 , int $count = 20 , string $action = 'view' , bool $getCount = false )
2017-04-16 02:16:07 +08:00
{
2020-06-27 20:29:00 +08:00
$query = $this -> buildEntitySearchQuery ( $searchOpts , $entityType , $action );
2018-01-29 00:58:52 +08:00
if ( $getCount ) {
return $query -> count ();
}
2017-04-16 02:16:07 +08:00
2021-06-26 23:23:15 +08:00
$query = $query -> skip (( $page - 1 ) * $count ) -> take ( $count );
2017-04-16 02:16:07 +08:00
return $query -> get ();
}
/**
2021-06-26 23:23:15 +08:00
* Create a search query for an entity .
2017-04-16 02:16:07 +08:00
*/
2020-06-27 20:29:00 +08:00
protected function buildEntitySearchQuery ( SearchOptions $searchOpts , string $entityType = 'page' , string $action = 'view' ) : EloquentBuilder
2017-03-27 02:24:57 +08:00
{
2018-09-26 01:00:40 +08:00
$entity = $this -> entityProvider -> get ( $entityType );
2017-03-27 18:57:33 +08:00
$entitySelect = $entity -> newQuery ();
// Handle normal search terms
2020-06-27 20:29:00 +08:00
if ( count ( $searchOpts -> searches ) > 0 ) {
2020-11-22 08:17:45 +08:00
$rawScoreSum = $this -> db -> raw ( 'SUM(score) as score' );
$subQuery = $this -> db -> table ( 'search_terms' ) -> select ( 'entity_id' , 'entity_type' , $rawScoreSum );
2018-09-26 01:00:40 +08:00
$subQuery -> where ( 'entity_type' , '=' , $entity -> getMorphClass ());
2020-06-27 20:29:00 +08:00
$subQuery -> where ( function ( Builder $query ) use ( $searchOpts ) {
foreach ( $searchOpts -> searches as $inputTerm ) {
2021-06-26 23:23:15 +08:00
$query -> orWhere ( 'term' , 'like' , $inputTerm . '%' );
2017-03-27 18:57:33 +08:00
}
}) -> groupBy ( 'entity_type' , 'entity_id' );
2020-11-22 08:17:45 +08:00
$entitySelect -> join ( $this -> db -> raw ( '(' . $subQuery -> toSql () . ') as s' ), function ( JoinClause $join ) {
2017-03-27 18:57:33 +08:00
$join -> on ( 'id' , '=' , 'entity_id' );
2021-06-26 23:23:15 +08:00
}) -> selectRaw ( $entity -> getTable () . '.*, s.score' ) -> orderBy ( 'score' , 'desc' );
2017-03-27 18:57:33 +08:00
$entitySelect -> mergeBindings ( $subQuery );
}
// Handle exact term matching
2020-11-22 08:17:45 +08:00
foreach ( $searchOpts -> exacts as $inputTerm ) {
$entitySelect -> where ( function ( EloquentBuilder $query ) use ( $inputTerm , $entity ) {
2021-06-26 23:23:15 +08:00
$query -> where ( 'name' , 'like' , '%' . $inputTerm . '%' )
-> orWhere ( $entity -> textField , 'like' , '%' . $inputTerm . '%' );
2017-03-27 18:57:33 +08:00
});
}
2017-03-28 01:05:34 +08:00
// Handle tag searches
2020-06-27 20:29:00 +08:00
foreach ( $searchOpts -> tags as $inputTerm ) {
2017-03-28 01:05:34 +08:00
$this -> applyTagSearch ( $entitySelect , $inputTerm );
}
// Handle filters
2020-06-27 20:29:00 +08:00
foreach ( $searchOpts -> filters as $filterTerm => $filterValue ) {
2019-09-14 06:58:40 +08:00
$functionName = Str :: camel ( 'filter_' . $filterTerm );
2018-01-29 00:58:52 +08:00
if ( method_exists ( $this , $functionName )) {
$this -> $functionName ( $entitySelect , $entity , $filterValue );
}
2017-03-28 01:05:34 +08:00
}
2021-03-15 04:32:33 +08:00
return $this -> permissionService -> enforceEntityRestrictions ( $entity , $entitySelect , $action );
2017-03-27 02:24:57 +08:00
}
2017-03-28 01:05:34 +08:00
/**
* Get the available query operators as a regex escaped list .
*/
2020-06-27 20:29:00 +08:00
protected function getRegexEscapedOperators () : string
2017-03-28 01:05:34 +08:00
{
$escapedOperators = [];
foreach ( $this -> queryOperators as $operator ) {
$escapedOperators [] = preg_quote ( $operator );
}
2021-06-26 23:23:15 +08:00
2017-03-28 01:05:34 +08:00
return join ( '|' , $escapedOperators );
}
/**
* Apply a tag search term onto a entity query .
*/
2020-06-27 20:29:00 +08:00
protected function applyTagSearch ( EloquentBuilder $query , string $tagTerm ) : EloquentBuilder
2018-01-29 00:58:52 +08:00
{
2021-06-26 23:23:15 +08:00
preg_match ( '/^(.*?)((' . $this -> getRegexEscapedOperators () . ')(.*?))?$/' , $tagTerm , $tagSplit );
2018-09-26 01:00:40 +08:00
$query -> whereHas ( 'tags' , function ( EloquentBuilder $query ) use ( $tagSplit ) {
2017-03-28 01:05:34 +08:00
$tagName = $tagSplit [ 1 ];
$tagOperator = count ( $tagSplit ) > 2 ? $tagSplit [ 3 ] : '' ;
$tagValue = count ( $tagSplit ) > 3 ? $tagSplit [ 4 ] : '' ;
$validOperator = in_array ( $tagOperator , $this -> queryOperators );
if ( ! empty ( $tagOperator ) && ! empty ( $tagValue ) && $validOperator ) {
2018-01-29 00:58:52 +08:00
if ( ! empty ( $tagName )) {
$query -> where ( 'name' , '=' , $tagName );
}
2017-03-28 01:05:34 +08:00
if ( is_numeric ( $tagValue ) && $tagOperator !== 'like' ) {
// We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
// search the value as a string which prevents being able to do number-based operations
// on the tag values. We ensure it has a numeric value and then cast it just to be sure.
$tagValue = ( float ) trim ( $query -> getConnection () -> getPdo () -> quote ( $tagValue ), " ' " );
$query -> whereRaw ( " value ${ tagOperator } ${ tagValue } " );
} else {
$query -> where ( 'value' , $tagOperator , $tagValue );
}
} else {
$query -> where ( 'name' , '=' , $tagName );
}
});
2021-06-26 23:23:15 +08:00
2017-03-28 01:05:34 +08:00
return $query ;
}
/**
2021-06-26 23:23:15 +08:00
* Custom entity search filters .
2017-03-28 01:05:34 +08:00
*/
2018-09-26 01:00:40 +08:00
protected function filterUpdatedAfter ( EloquentBuilder $query , Entity $model , $input )
2017-03-28 01:05:34 +08:00
{
2018-01-29 00:58:52 +08:00
try {
$date = date_create ( $input );
} catch ( \Exception $e ) {
return ;
}
2017-03-28 01:05:34 +08:00
$query -> where ( 'updated_at' , '>=' , $date );
}
2018-09-26 01:00:40 +08:00
protected function filterUpdatedBefore ( EloquentBuilder $query , Entity $model , $input )
2017-03-28 01:05:34 +08:00
{
2018-01-29 00:58:52 +08:00
try {
$date = date_create ( $input );
} catch ( \Exception $e ) {
return ;
}
2017-03-28 01:05:34 +08:00
$query -> where ( 'updated_at' , '<' , $date );
}
2018-09-26 01:00:40 +08:00
protected function filterCreatedAfter ( EloquentBuilder $query , Entity $model , $input )
2017-03-28 01:05:34 +08:00
{
2018-01-29 00:58:52 +08:00
try {
$date = date_create ( $input );
} catch ( \Exception $e ) {
return ;
}
2017-03-28 01:05:34 +08:00
$query -> where ( 'created_at' , '>=' , $date );
}
2018-09-26 01:00:40 +08:00
protected function filterCreatedBefore ( EloquentBuilder $query , Entity $model , $input )
2017-03-28 01:05:34 +08:00
{
2018-01-29 00:58:52 +08:00
try {
$date = date_create ( $input );
} catch ( \Exception $e ) {
return ;
}
2017-03-28 01:05:34 +08:00
$query -> where ( 'created_at' , '<' , $date );
}
2018-09-26 01:00:40 +08:00
protected function filterCreatedBy ( EloquentBuilder $query , Entity $model , $input )
2017-03-28 01:05:34 +08:00
{
2021-03-11 06:51:18 +08:00
$userSlug = $input === 'me' ? user () -> slug : trim ( $input );
$user = User :: query () -> where ( 'slug' , '=' , $userSlug ) -> first ([ 'id' ]);
if ( $user ) {
$query -> where ( 'created_by' , '=' , $user -> id );
2018-01-29 00:58:52 +08:00
}
2017-03-28 01:05:34 +08:00
}
2018-09-26 01:00:40 +08:00
protected function filterUpdatedBy ( EloquentBuilder $query , Entity $model , $input )
2017-03-28 01:05:34 +08:00
{
2021-03-11 06:51:18 +08:00
$userSlug = $input === 'me' ? user () -> slug : trim ( $input );
$user = User :: query () -> where ( 'slug' , '=' , $userSlug ) -> first ([ 'id' ]);
if ( $user ) {
$query -> where ( 'updated_by' , '=' , $user -> id );
2018-01-29 00:58:52 +08:00
}
2017-03-28 01:05:34 +08:00
}
2021-02-14 18:39:18 +08:00
protected function filterOwnedBy ( EloquentBuilder $query , Entity $model , $input )
{
2021-03-16 02:27:03 +08:00
$userSlug = $input === 'me' ? user () -> slug : trim ( $input );
$user = User :: query () -> where ( 'slug' , '=' , $userSlug ) -> first ([ 'id' ]);
if ( $user ) {
$query -> where ( 'owned_by' , '=' , $user -> id );
2021-02-14 18:39:18 +08:00
}
}
2018-09-26 01:00:40 +08:00
protected function filterInName ( EloquentBuilder $query , Entity $model , $input )
2017-03-28 01:05:34 +08:00
{
2021-06-26 23:23:15 +08:00
$query -> where ( 'name' , 'like' , '%' . $input . '%' );
2017-03-28 01:05:34 +08:00
}
2018-09-26 01:00:40 +08:00
protected function filterInTitle ( EloquentBuilder $query , Entity $model , $input )
2018-01-29 00:58:52 +08:00
{
$this -> filterInName ( $query , $model , $input );
}
2017-03-28 01:05:34 +08:00
2018-09-26 01:00:40 +08:00
protected function filterInBody ( EloquentBuilder $query , Entity $model , $input )
2017-03-28 01:05:34 +08:00
{
2021-06-26 23:23:15 +08:00
$query -> where ( $model -> textField , 'like' , '%' . $input . '%' );
2017-03-28 01:05:34 +08:00
}
2018-09-26 01:00:40 +08:00
protected function filterIsRestricted ( EloquentBuilder $query , Entity $model , $input )
2017-03-28 01:05:34 +08:00
{
$query -> where ( 'restricted' , '=' , true );
}
2018-09-26 01:00:40 +08:00
protected function filterViewedByMe ( EloquentBuilder $query , Entity $model , $input )
2017-03-28 01:05:34 +08:00
{
2018-01-29 00:58:52 +08:00
$query -> whereHas ( 'views' , function ( $query ) {
2017-03-28 01:05:34 +08:00
$query -> where ( 'user_id' , '=' , user () -> id );
});
}
2018-09-26 01:00:40 +08:00
protected function filterNotViewedByMe ( EloquentBuilder $query , Entity $model , $input )
2017-03-28 01:05:34 +08:00
{
2018-01-29 00:58:52 +08:00
$query -> whereDoesntHave ( 'views' , function ( $query ) {
2017-03-28 01:05:34 +08:00
$query -> where ( 'user_id' , '=' , user () -> id );
});
}
2018-09-26 01:00:40 +08:00
protected function filterSortBy ( EloquentBuilder $query , Entity $model , $input )
2017-10-01 18:24:13 +08:00
{
2019-09-14 06:58:40 +08:00
$functionName = Str :: camel ( 'sort_by_' . $input );
2018-01-29 00:58:52 +08:00
if ( method_exists ( $this , $functionName )) {
$this -> $functionName ( $query , $model );
}
2017-10-01 18:24:13 +08:00
}
/**
2021-06-26 23:23:15 +08:00
* Sorting filter options .
2017-10-01 18:24:13 +08:00
*/
2018-09-26 01:00:40 +08:00
protected function sortByLastCommented ( EloquentBuilder $query , Entity $model )
2017-10-01 18:24:13 +08:00
{
$commentsTable = $this -> db -> getTablePrefix () . 'comments' ;
$morphClass = str_replace ( '\\' , '\\\\' , $model -> getMorphClass ());
2021-06-26 23:23:15 +08:00
$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' );
2017-10-01 18:24:13 +08:00
$query -> join ( $commentQuery , $model -> getTable () . '.id' , '=' , 'comments.entity_id' ) -> orderBy ( 'last_commented' , 'desc' );
}
2018-01-29 00:58:52 +08:00
}