diff --git a/app/Search/Options/TagSearchOption.php b/app/Search/Options/TagSearchOption.php index e61c55ae4..dd2803db4 100644 --- a/app/Search/Options/TagSearchOption.php +++ b/app/Search/Options/TagSearchOption.php @@ -4,8 +4,34 @@ namespace BookStack\Search\Options; class TagSearchOption extends SearchOption { + /** + * Acceptable operators to be used within a tag search option. + * + * @var string[] + */ + protected array $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!=']; + public function toString(): string { return ($this->negated ? '-' : '') . "[{$this->value}]"; } + + /** + * @return array{name: string, operator: string, value: string} + */ + public function getParts(): array + { + $operatorRegex = implode('|', array_map(fn($op) => preg_quote($op), $this->queryOperators)); + preg_match('/^(.*?)((' . $operatorRegex . ')(.*?))?$/', $this->value, $tagSplit); + + $extractedOperator = count($tagSplit) > 2 ? $tagSplit[3] : ''; + $tagOperator = in_array($extractedOperator, $this->queryOperators) ? $extractedOperator : '='; + $tagValue = count($tagSplit) > 3 ? $tagSplit[4] : ''; + + return [ + 'name' => $tagSplit[1], + 'operator' => $tagOperator, + 'value' => $tagValue, + ]; + } } diff --git a/app/Search/SearchOptionSet.php b/app/Search/SearchOptionSet.php index 467dc9f5a..f421abe2c 100644 --- a/app/Search/SearchOptionSet.php +++ b/app/Search/SearchOptionSet.php @@ -4,10 +4,13 @@ namespace BookStack\Search; use BookStack\Search\Options\SearchOption; +/** + * @template T of SearchOption + */ class SearchOptionSet { /** - * @var SearchOption[] + * @var T[] */ protected array $options = []; @@ -52,7 +55,7 @@ class SearchOptionSet } /** - * @return SearchOption[] + * @return T[] */ public function all(): array { @@ -60,7 +63,7 @@ class SearchOptionSet } /** - * @return SearchOption[] + * @return T[] */ public function negated(): array { diff --git a/app/Search/SearchOptions.php b/app/Search/SearchOptions.php index 98f731ee7..e362a8a01 100644 --- a/app/Search/SearchOptions.php +++ b/app/Search/SearchOptions.php @@ -11,9 +11,13 @@ use Illuminate\Http\Request; class SearchOptions { + /** @var SearchOptionSet<TermSearchOption> */ public SearchOptionSet $searches; + /** @var SearchOptionSet<ExactSearchOption> */ public SearchOptionSet $exacts; + /** @var SearchOptionSet<TagSearchOption> */ public SearchOptionSet $tags; + /** @var SearchOptionSet<FilterSearchOption> */ public SearchOptionSet $filters; public function __construct() diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index 855140508..265b9eecc 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -7,6 +7,7 @@ use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; 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; @@ -16,31 +17,21 @@ use Illuminate\Database\Query\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; -use SplObjectStorage; +use WeakMap; class SearchRunner { - /** - * Acceptable operators to be used in a query. - * - * @var string[] - */ - protected array $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!=']; - /** * Retain a cache of score adjusted terms for specific search options. - * From PHP>=8 this can be made into a WeakMap instead. - * - * @var SplObjectStorage */ - protected $termAdjustmentCache; + protected WeakMap $termAdjustmentCache; public function __construct( protected EntityProvider $entityProvider, protected PermissionApplicator $permissions, protected EntityQueries $entityQueries, ) { - $this->termAdjustmentCache = new SplObjectStorage(); + $this->termAdjustmentCache = new WeakMap(); } /** @@ -163,24 +154,26 @@ class SearchRunner $this->applyTermSearch($entityQuery, $searchOpts, $entityType); // Handle exact term matching - foreach ($searchOpts->exacts->toValueArray() as $inputTerm) { - $entityQuery->where(function (EloquentBuilder $query) use ($inputTerm, $entityModelInstance) { - $inputTerm = str_replace('\\', '\\\\', $inputTerm); + foreach ($searchOpts->exacts->all() as $exact) { + $filter = function (EloquentBuilder $query) use ($exact, $entityModelInstance) { + $inputTerm = str_replace('\\', '\\\\', $exact->value); $query->where('name', 'like', '%' . $inputTerm . '%') ->orWhere($entityModelInstance->textField, 'like', '%' . $inputTerm . '%'); - }); + }; + + $exact->negated ? $entityQuery->whereNot($filter) : $entityQuery->where($filter); } // Handle tag searches - foreach ($searchOpts->tags->toValueArray() as $inputTerm) { - $this->applyTagSearch($entityQuery, $inputTerm); + foreach ($searchOpts->tags->all() as $tagOption) { + $this->applyTagSearch($entityQuery, $tagOption); } // Handle filters - foreach ($searchOpts->filters->toValueMap() as $filterTerm => $filterValue) { - $functionName = Str::camel('filter_' . $filterTerm); + foreach ($searchOpts->filters->all() as $filterOption) { + $functionName = Str::camel('filter_' . $filterOption->getKey()); if (method_exists($this, $functionName)) { - $this->$functionName($entityQuery, $entityModelInstance, $filterValue); + $this->$functionName($entityQuery, $entityModelInstance, $filterOption->value, $filterOption->negated); } } @@ -312,179 +305,165 @@ class SearchRunner } /** - * Get the available query operators as a regex escaped list. + * Apply a tag search term onto an entity query. */ - protected function getRegexEscapedOperators(): string + protected function applyTagSearch(EloquentBuilder $query, TagSearchOption $option): void { - $escapedOperators = []; - foreach ($this->queryOperators as $operator) { - $escapedOperators[] = preg_quote($operator); - } + $filter = function (EloquentBuilder $query) use ($option): void { + $tagParts = $option->getParts(); + if (empty($tagParts['operator']) || empty($tagParts['value'])) { + $query->where('name', '=', $tagParts['name']); + return; + } - return implode('|', $escapedOperators); + if (!empty($tagParts['name'])) { + $query->where('name', '=', $tagParts['name']); + } + + if (is_numeric($tagParts['value']) && $tagParts['operator'] !== '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. + /** @var Connection $connection */ + $connection = $query->getConnection(); + $quotedValue = (float) trim($connection->getPdo()->quote($tagParts['value']), "'"); + $query->whereRaw("value {$tagParts['operator']} {$quotedValue}"); + } else if ($tagParts['operator'] === 'like') { + $query->where('value', $tagParts['operator'], str_replace('\\', '\\\\', $tagParts['value'])); + } else { + $query->where('value', $tagParts['operator'], $tagParts['value']); + } + }; + + $option->negated ? $query->whereDoesntHave('tags', $filter) : $query->whereHas('tags', $filter); } - /** - * Apply a tag search term onto a entity query. - */ - protected function applyTagSearch(EloquentBuilder $query, string $tagTerm): EloquentBuilder + protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string $column, string $operator, mixed $value): void { - preg_match('/^(.*?)((' . $this->getRegexEscapedOperators() . ')(.*?))?$/', $tagTerm, $tagSplit); - $query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) { - $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) { - if (!empty($tagName)) { - $query->where('name', '=', $tagName); - } - 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. - /** @var Connection $connection */ - $connection = $query->getConnection(); - $tagValue = (float) trim($connection->getPdo()->quote($tagValue), "'"); - $query->whereRaw("value {$tagOperator} {$tagValue}"); - } else { - if ($tagOperator === 'like') { - $tagValue = str_replace('\\', '\\\\', $tagValue); - } - $query->where('value', $tagOperator, $tagValue); - } - } else { - $query->where('name', '=', $tagName); - } - }); - - return $query; + if ($negated) { + $query->whereNot($column, $operator, $value); + } else { + $query->where($column, $operator, $value); + } } /** * Custom entity search filters. */ - protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, $input): void + protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void { - try { - $date = date_create($input); - $query->where('updated_at', '>=', $date); - } catch (\Exception $e) { - } + $date = date_create($input); + $this->applyNegatableWhere($query, $negated, 'updated_at', '>=', $date); } - protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, $input): void + protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated): void { - try { - $date = date_create($input); - $query->where('updated_at', '<', $date); - } catch (\Exception $e) { - } + $date = date_create($input); + $this->applyNegatableWhere($query, $negated, 'updated_at', '<', $date); } - protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, $input): void + protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void { - try { - $date = date_create($input); - $query->where('created_at', '>=', $date); - } catch (\Exception $e) { - } + $date = date_create($input); + $this->applyNegatableWhere($query, $negated, 'created_at', '>=', $date); } - protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, $input) + protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated) { - try { - $date = date_create($input); - $query->where('created_at', '<', $date); - } catch (\Exception $e) { - } + $date = date_create($input); + $this->applyNegatableWhere($query, $negated, 'created_at', '<', $date); } - protected function filterCreatedBy(EloquentBuilder $query, Entity $model, $input) + protected function filterCreatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated) { $userSlug = $input === 'me' ? user()->slug : trim($input); $user = User::query()->where('slug', '=', $userSlug)->first(['id']); if ($user) { - $query->where('created_by', '=', $user->id); + $this->applyNegatableWhere($query, $negated, 'created_by', '=', $user->id); } } - protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, $input) + protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated) { $userSlug = $input === 'me' ? user()->slug : trim($input); $user = User::query()->where('slug', '=', $userSlug)->first(['id']); if ($user) { - $query->where('updated_by', '=', $user->id); + $this->applyNegatableWhere($query, $negated, 'updated_by', '=', $user->id); } } - protected function filterOwnedBy(EloquentBuilder $query, Entity $model, $input) + protected function filterOwnedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated) { $userSlug = $input === 'me' ? user()->slug : trim($input); $user = User::query()->where('slug', '=', $userSlug)->first(['id']); if ($user) { - $query->where('owned_by', '=', $user->id); + $this->applyNegatableWhere($query, $negated, 'owned_by', '=', $user->id); } } - protected function filterInName(EloquentBuilder $query, Entity $model, $input) + protected function filterInName(EloquentBuilder $query, Entity $model, string $input, bool $negated) { - $query->where('name', 'like', '%' . $input . '%'); + $this->applyNegatableWhere($query, $negated, 'name', 'like', '%' . $input . '%'); } - protected function filterInTitle(EloquentBuilder $query, Entity $model, $input) + protected function filterInTitle(EloquentBuilder $query, Entity $model, string $input, bool $negated) { - $this->filterInName($query, $model, $input); + $this->filterInName($query, $model, $input, $negated); } - protected function filterInBody(EloquentBuilder $query, Entity $model, $input) + protected function filterInBody(EloquentBuilder $query, Entity $model, string $input, bool $negated) { - $query->where($model->textField, 'like', '%' . $input . '%'); + $this->applyNegatableWhere($query, $negated, $model->textField, 'like', '%' . $input . '%'); } - protected function filterIsRestricted(EloquentBuilder $query, Entity $model, $input) + protected function filterIsRestricted(EloquentBuilder $query, Entity $model, string $input, bool $negated) { - $query->whereHas('permissions'); + $negated ? $query->whereDoesntHave('permissions') : $query->whereHas('permissions'); } - protected function filterViewedByMe(EloquentBuilder $query, Entity $model, $input) + protected function filterViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated) { - $query->whereHas('views', function ($query) { + $filter = function ($query) { $query->where('user_id', '=', user()->id); - }); + }; + + $negated ? $query->whereDoesntHave('views', $filter) : $query->whereHas('views', $filter); } - protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, $input) + protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated) { - $query->whereDoesntHave('views', function ($query) { + $filter = function ($query) { $query->where('user_id', '=', user()->id); - }); + }; + + $negated ? $query->whereHas('views', $filter) : $query->whereDoesntHave('views', $filter); } - protected function filterIsTemplate(EloquentBuilder $query, Entity $model, $input) + protected function filterIsTemplate(EloquentBuilder $query, Entity $model, string $input, bool $negated) { if ($model instanceof Page) { - $query->where('template', '=', true); + $this->applyNegatableWhere($query, $negated, 'template', '=', true); } } - protected function filterSortBy(EloquentBuilder $query, Entity $model, $input) + protected function filterSortBy(EloquentBuilder $query, Entity $model, string $input, bool $negated) { $functionName = Str::camel('sort_by_' . $input); if (method_exists($this, $functionName)) { - $this->$functionName($query, $model); + $this->$functionName($query, $model, $negated); } } /** * Sorting filter options. */ - protected function sortByLastCommented(EloquentBuilder $query, Entity $model) + protected function sortByLastCommented(EloquentBuilder $query, Entity $model, bool $negated) { $commentsTable = DB::getTablePrefix() . 'comments'; $morphClass = str_replace('\\', '\\\\', $model->getMorphClass()); $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'); - $query->join($commentQuery, $model->getTable() . '.id', '=', 'comments.entity_id')->orderBy('last_commented', 'desc'); + $query->join($commentQuery, $model->getTable() . '.id', '=', 'comments.entity_id') + ->orderBy('last_commented', $negated ? 'asc' : 'desc'); } } diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php index c3e959345..bb1021a67 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -118,6 +118,32 @@ class EntitySearchTest extends TestCase $exactSearchB->assertStatus(200)->assertDontSee($page->name); } + public function test_negated_searches() + { + $page = $this->entities->newPage(['name' => 'My new test negation page', 'html' => '<p>An angry tortoise wore trumpeted plimsoles</p>']); + $page->tags()->saveMany([new Tag(['name' => 'DonkCount', 'value' => '500'])]); + $page->created_by = $this->users->admin()->id; + $page->save(); + + $editor = $this->users->editor(); + $this->actingAs($editor); + + $exactSearch = $this->get('/search?term=' . urlencode('negation -"tortoise"')); + $exactSearch->assertStatus(200)->assertDontSeeText($page->name); + + $tagSearchA = $this->get('/search?term=' . urlencode('negation [DonkCount=500]')); + $tagSearchA->assertStatus(200)->assertSeeText($page->name); + $tagSearchB = $this->get('/search?term=' . urlencode('negation -[DonkCount=500]')); + $tagSearchB->assertStatus(200)->assertDontSeeText($page->name); + + $filterSearchA = $this->get('/search?term=' . urlencode('negation -{created_by:me}')); + $filterSearchA->assertStatus(200)->assertSeeText($page->name); + $page->created_by = $editor->id; + $page->save(); + $filterSearchB = $this->get('/search?term=' . urlencode('negation -{created_by:me}')); + $filterSearchB->assertStatus(200)->assertDontSeeText($page->name); + } + public function test_search_terms_with_delimiters_are_converted_to_exact_matches() { $this->asEditor();