diff --git a/src/Api/Controller/ListDiscussionsController.php b/src/Api/Controller/ListDiscussionsController.php index dc825d9cd..c2b4af6d9 100644 --- a/src/Api/Controller/ListDiscussionsController.php +++ b/src/Api/Controller/ListDiscussionsController.php @@ -11,10 +11,10 @@ namespace Flarum\Api\Controller; use Flarum\Api\Serializer\DiscussionSerializer; use Flarum\Discussion\Discussion; +use Flarum\Discussion\Filter\DiscussionFilterer; use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Http\UrlGenerator; use Flarum\Search\SearchCriteria; -use Illuminate\Support\Arr; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -43,11 +43,21 @@ class ListDiscussionsController extends AbstractListController 'lastPost' ]; + /** + * {@inheritDoc} + */ + public $sort = ['lastPostedAt' => 'desc']; + /** * {@inheritdoc} */ public $sortFields = ['lastPostedAt', 'commentCount', 'createdAt']; + /** + * @var DiscussionFilterer + */ + protected $filterer; + /** * @var DiscussionSearcher */ @@ -59,11 +69,13 @@ class ListDiscussionsController extends AbstractListController protected $url; /** + * @param DiscussionFilterer $filterer * @param DiscussionSearcher $searcher * @param UrlGenerator $url */ - public function __construct(DiscussionSearcher $searcher, UrlGenerator $url) + public function __construct(DiscussionFilterer $filterer, DiscussionSearcher $searcher, UrlGenerator $url) { + $this->filterer = $filterer; $this->searcher = $searcher; $this->url = $url; } @@ -74,16 +86,19 @@ class ListDiscussionsController extends AbstractListController protected function data(ServerRequestInterface $request, Document $document) { $actor = $request->getAttribute('actor'); - $query = Arr::get($this->extractFilter($request), 'q'); + $filters = $this->extractFilter($request); $sort = $this->extractSort($request); - $criteria = new SearchCriteria($actor, $query, $sort); - $limit = $this->extractLimit($request); $offset = $this->extractOffset($request); - $load = array_merge($this->extractInclude($request), ['state']); + $include = array_merge($this->extractInclude($request), ['state']); - $results = $this->searcher->search($criteria, $limit, $offset); + $criteria = new SearchCriteria($actor, $filters, $sort); + if (array_key_exists('q', $filters)) { + $results = $this->searcher->search($criteria, $limit, $offset); + } else { + $results = $this->filterer->filter($criteria, $limit, $offset); + } $document->addPaginationLinks( $this->url->to('api')->route('discussions.index'), @@ -95,9 +110,9 @@ class ListDiscussionsController extends AbstractListController Discussion::setStateUser($actor); - $results = $results->getResults()->load($load); + $results = $results->getResults()->load($include); - if ($relations = array_intersect($load, ['firstPost', 'lastPost'])) { + if ($relations = array_intersect($include, ['firstPost', 'lastPost'])) { foreach ($results as $discussion) { foreach ($relations as $relation) { if ($discussion->$relation) { diff --git a/src/Api/Controller/ListUsersController.php b/src/Api/Controller/ListUsersController.php index 5f44c0984..6e7bb4f84 100644 --- a/src/Api/Controller/ListUsersController.php +++ b/src/Api/Controller/ListUsersController.php @@ -12,8 +12,8 @@ namespace Flarum\Api\Controller; use Flarum\Api\Serializer\UserSerializer; use Flarum\Http\UrlGenerator; use Flarum\Search\SearchCriteria; +use Flarum\User\Filter\UserFilterer; use Flarum\User\Search\UserSearcher; -use Illuminate\Support\Arr; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -40,6 +40,11 @@ class ListUsersController extends AbstractListController 'joinedAt' ]; + /** + * @var UserFilterer + */ + protected $filterer; + /** * @var UserSearcher */ @@ -51,11 +56,13 @@ class ListUsersController extends AbstractListController protected $url; /** + * @param UserFilterer $filterer * @param UserSearcher $searcher * @param UrlGenerator $url */ - public function __construct(UserSearcher $searcher, UrlGenerator $url) + public function __construct(UserFilterer $filterer, UserSearcher $searcher, UrlGenerator $url) { + $this->filterer = $filterer; $this->searcher = $searcher; $this->url = $url; } @@ -69,16 +76,19 @@ class ListUsersController extends AbstractListController $actor->assertCan('viewUserList'); - $query = Arr::get($this->extractFilter($request), 'q'); + $filters = $this->extractFilter($request); $sort = $this->extractSort($request); - $criteria = new SearchCriteria($actor, $query, $sort); - $limit = $this->extractLimit($request); $offset = $this->extractOffset($request); - $load = $this->extractInclude($request); + $include = $this->extractInclude($request); - $results = $this->searcher->search($criteria, $limit, $offset, $load); + $criteria = new SearchCriteria($actor, $filters, $sort); + if (array_key_exists('q', $filters)) { + $results = $this->searcher->search($criteria, $limit, $offset); + } else { + $results = $this->filterer->filter($criteria, $limit, $offset); + } $document->addPaginationLinks( $this->url->to('api')->route('users.index'), @@ -88,6 +98,6 @@ class ListUsersController extends AbstractListController $results->areMoreResults() ? null : 0 ); - return $results->getResults(); + return $results->getResults()->load($include); } } diff --git a/src/Discussion/Event/Searching.php b/src/Discussion/Event/Searching.php index 138a4f469..dde2b47b9 100644 --- a/src/Discussion/Event/Searching.php +++ b/src/Discussion/Event/Searching.php @@ -9,8 +9,8 @@ namespace Flarum\Discussion\Event; -use Flarum\Discussion\Search\DiscussionSearch; use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchState; /** * @deprecated beta 16, remove beta 17 @@ -18,7 +18,7 @@ use Flarum\Search\SearchCriteria; class Searching { /** - * @var DiscussionSearch + * @var SearchState */ public $search; @@ -28,10 +28,10 @@ class Searching public $criteria; /** - * @param DiscussionSearch $search + * @param SearchState $search * @param \Flarum\Search\SearchCriteria $criteria */ - public function __construct(DiscussionSearch $search, SearchCriteria $criteria) + public function __construct(SearchState $search, SearchCriteria $criteria) { $this->search = $search; $this->criteria = $criteria; diff --git a/src/Discussion/Filter/AuthorFilterGambit.php b/src/Discussion/Filter/AuthorFilterGambit.php new file mode 100644 index 000000000..616b8be38 --- /dev/null +++ b/src/Discussion/Filter/AuthorFilterGambit.php @@ -0,0 +1,72 @@ +users = $users; + } + + /** + * {@inheritdoc} + */ + public function getGambitPattern() + { + return 'author:(.+)'; + } + + /** + * {@inheritdoc} + */ + protected function conditions(SearchState $search, array $matches, $negate) + { + $this->constrain($search->getQuery(), $matches[1], $negate); + } + + public function getFilterKey(): string + { + return 'author'; + } + + public function filter(FilterState $filterState, string $filterValue, bool $negate) + { + $this->constrain($filterState->getQuery(), $filterValue, $negate); + } + + protected function constrain(Builder $query, $rawUsernames, $negate) + { + $usernames = trim($rawUsernames, '"'); + $usernames = explode(',', $usernames); + + $ids = []; + foreach ($usernames as $username) { + $ids[] = $this->users->getIdForUsername($username); + } + + $query->whereIn('discussions.user_id', $ids, 'and', $negate); + } +} diff --git a/src/Discussion/Filter/CreatedFilterGambit.php b/src/Discussion/Filter/CreatedFilterGambit.php new file mode 100644 index 000000000..638d84884 --- /dev/null +++ b/src/Discussion/Filter/CreatedFilterGambit.php @@ -0,0 +1,61 @@ +constrain($search->getQuery(), Arr::get($matches, 1), Arr::get($matches, 3), $negate); + } + + public function getFilterKey(): string + { + return 'created'; + } + + public function filter(FilterState $filterState, string $filterValue, bool $negate) + { + preg_match('/^'.$this->getGambitPattern().'$/i', 'created:'.$filterValue, $matches); + + $this->constrain($filterState->getQuery(), Arr::get($matches, 1), Arr::get($matches, 3), $negate); + } + + public function constrain(Builder $query, ?string $firstDate, ?string $secondDate, $negate) + { + // If we've just been provided with a single YYYY-MM-DD date, then find + // discussions that were started on that exact date. But if we've been + // provided with a YYYY-MM-DD..YYYY-MM-DD range, then find discussions + // that were started during that period. + if (empty($secondDate)) { + $query->whereDate('created_at', $negate ? '!=' : '=', $firstDate); + } else { + $query->whereBetween('created_at', [$firstDate, $secondDate], 'and', $negate); + } + } +} diff --git a/src/Discussion/Filter/DiscussionFilterer.php b/src/Discussion/Filter/DiscussionFilterer.php new file mode 100644 index 000000000..8ada10502 --- /dev/null +++ b/src/Discussion/Filter/DiscussionFilterer.php @@ -0,0 +1,40 @@ +discussions = $discussions; + } + + protected function getQuery(User $actor): Builder + { + return $this->discussions->query()->select('discussions.*')->whereVisibleTo($actor); + } +} diff --git a/src/Discussion/Filter/HiddenFilterGambit.php b/src/Discussion/Filter/HiddenFilterGambit.php new file mode 100644 index 000000000..133167a36 --- /dev/null +++ b/src/Discussion/Filter/HiddenFilterGambit.php @@ -0,0 +1,56 @@ +constrain($search->getQuery(), $negate); + } + + public function getFilterKey(): string + { + return 'hidden'; + } + + public function filter(FilterState $filterState, string $filterValue, bool $negate) + { + $this->constrain($filterState->getQuery(), $negate); + } + + protected function constrain(Builder $query, bool $negate) + { + $query->where(function ($query) use ($negate) { + if ($negate) { + $query->whereNull('hidden_at')->where('comment_count', '>', 0); + } else { + $query->whereNotNull('hidden_at')->orWhere('comment_count', 0); + } + }); + } +} diff --git a/src/Discussion/Search/Gambit/UnreadGambit.php b/src/Discussion/Filter/UnreadFilterGambit.php similarity index 53% rename from src/Discussion/Search/Gambit/UnreadGambit.php rename to src/Discussion/Filter/UnreadFilterGambit.php index 33aac7c91..06e6efb8e 100644 --- a/src/Discussion/Search/Gambit/UnreadGambit.php +++ b/src/Discussion/Filter/UnreadFilterGambit.php @@ -7,21 +7,18 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Discussion\Search\Gambit; +namespace Flarum\Discussion\Filter; use Flarum\Discussion\DiscussionRepository; -use Flarum\Discussion\Search\DiscussionSearch; +use Flarum\Filter\FilterInterface; +use Flarum\Filter\FilterState; use Flarum\Search\AbstractRegexGambit; -use Flarum\Search\AbstractSearch; -use LogicException; +use Flarum\Search\SearchState; +use Flarum\User\User; +use Illuminate\Database\Query\Builder; -class UnreadGambit extends AbstractRegexGambit +class UnreadFilterGambit extends AbstractRegexGambit implements FilterInterface { - /** - * {@inheritdoc} - */ - protected $pattern = 'is:unread'; - /** * @var \Flarum\Discussion\DiscussionRepository */ @@ -38,18 +35,35 @@ class UnreadGambit extends AbstractRegexGambit /** * {@inheritdoc} */ - protected function conditions(AbstractSearch $search, array $matches, $negate) + public function getGambitPattern() { - if (! $search instanceof DiscussionSearch) { - throw new LogicException('This gambit can only be applied on a DiscussionSearch'); - } + return 'is:unread'; + } - $actor = $search->getActor(); + /** + * {@inheritdoc} + */ + protected function conditions(SearchState $search, array $matches, $negate) + { + $this->constrain($search->getQuery(), $search->getActor(), $negate); + } + public function getFilterKey(): string + { + return 'unread'; + } + + public function filter(FilterState $filterState, string $filterValue, bool $negate) + { + $this->constrain($filterState->getQuery(), $filterState->getActor(), $negate); + } + + protected function constrain(Builder $query, User $actor, bool $negate) + { if ($actor->exists) { $readIds = $this->discussions->getReadIds($actor); - $search->getQuery()->where(function ($query) use ($readIds, $negate, $actor) { + $query->where(function ($query) use ($readIds, $negate, $actor) { if (! $negate) { $query->whereNotIn('id', $readIds)->where('last_posted_at', '>', $actor->marked_all_as_read_at ?: 0); } else { diff --git a/src/Discussion/Search/DiscussionSearch.php b/src/Discussion/Search/DiscussionSearch.php deleted file mode 100644 index 4a07492b2..000000000 --- a/src/Discussion/Search/DiscussionSearch.php +++ /dev/null @@ -1,51 +0,0 @@ - 'desc']; - - /** - * @var array - */ - protected $relevantPostIds = []; - - /** - * Get the related IDs for each result. - * - * @return int[] - */ - public function getRelevantPostIds() - { - return $this->relevantPostIds; - } - - /** - * Set the relevant post IDs for the results. - * - * @param array $relevantPostIds - * @return void - */ - public function setRelevantPostIds(array $relevantPostIds) - { - $this->relevantPostIds = $relevantPostIds; - } -} diff --git a/src/Discussion/Search/DiscussionSearcher.php b/src/Discussion/Search/DiscussionSearcher.php index edc755b8c..05a16a3bb 100644 --- a/src/Discussion/Search/DiscussionSearcher.php +++ b/src/Discussion/Search/DiscussionSearcher.php @@ -11,10 +11,10 @@ namespace Flarum\Discussion\Search; use Flarum\Discussion\DiscussionRepository; use Flarum\Discussion\Event\Searching; -use Flarum\Search\AbstractSearch; use Flarum\Search\AbstractSearcher; use Flarum\Search\GambitManager; use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchState; use Flarum\User\User; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Eloquent\Builder; @@ -50,15 +50,10 @@ class DiscussionSearcher extends AbstractSearcher return $this->discussions->query()->select('discussions.*')->whereVisibleTo($actor); } - protected function getSearch(Builder $query, User $actor): AbstractSearch - { - return new DiscussionSearch($query->getQuery(), $actor); - } - /** * @deprecated along with the Searching event, remove in Beta 17. */ - protected function mutateSearch(AbstractSearch $search, SearchCriteria $criteria) + protected function mutateSearch(SearchState $search, SearchCriteria $criteria) { parent::mutateSearch($search, $criteria); diff --git a/src/Discussion/Search/Gambit/AuthorGambit.php b/src/Discussion/Search/Gambit/AuthorGambit.php deleted file mode 100644 index 819a8885b..000000000 --- a/src/Discussion/Search/Gambit/AuthorGambit.php +++ /dev/null @@ -1,57 +0,0 @@ -users = $users; - } - - /** - * {@inheritdoc} - */ - protected function conditions(AbstractSearch $search, array $matches, $negate) - { - if (! $search instanceof DiscussionSearch) { - throw new LogicException('This gambit can only be applied on a DiscussionSearch'); - } - - $usernames = trim($matches[1], '"'); - $usernames = explode(',', $usernames); - - $ids = []; - foreach ($usernames as $username) { - $ids[] = $this->users->getIdForUsername($username); - } - - $search->getQuery()->whereIn('discussions.user_id', $ids, 'and', $negate); - } -} diff --git a/src/Discussion/Search/Gambit/CreatedGambit.php b/src/Discussion/Search/Gambit/CreatedGambit.php deleted file mode 100644 index 3d24d5f0f..000000000 --- a/src/Discussion/Search/Gambit/CreatedGambit.php +++ /dev/null @@ -1,43 +0,0 @@ -getQuery()->whereDate('created_at', $negate ? '!=' : '=', $matches[1]); - } else { - $search->getQuery()->whereBetween('created_at', [$matches[1], $matches[3]], 'and', $negate); - } - } -} diff --git a/src/Discussion/Search/Gambit/FulltextGambit.php b/src/Discussion/Search/Gambit/FulltextGambit.php index ea3fe3ede..f57e13c76 100644 --- a/src/Discussion/Search/Gambit/FulltextGambit.php +++ b/src/Discussion/Search/Gambit/FulltextGambit.php @@ -9,24 +9,18 @@ namespace Flarum\Discussion\Search\Gambit; -use Flarum\Discussion\Search\DiscussionSearch; use Flarum\Post\Post; -use Flarum\Search\AbstractSearch; use Flarum\Search\GambitInterface; +use Flarum\Search\SearchState; use Illuminate\Database\Query\Expression; -use LogicException; class FulltextGambit implements GambitInterface { /** * {@inheritdoc} */ - public function apply(AbstractSearch $search, $bit) + public function apply(SearchState $search, $bit) { - if (! $search instanceof DiscussionSearch) { - throw new LogicException('This gambit can only be applied on a DiscussionSearch'); - } - // Replace all non-word characters with spaces. // We do this to prevent MySQL fulltext search boolean mode from taking // effect: https://dev.mysql.com/doc/refman/5.7/en/fulltext-boolean.html diff --git a/src/Discussion/Search/Gambit/HiddenGambit.php b/src/Discussion/Search/Gambit/HiddenGambit.php deleted file mode 100644 index e658ba0e6..000000000 --- a/src/Discussion/Search/Gambit/HiddenGambit.php +++ /dev/null @@ -1,41 +0,0 @@ -getQuery()->where(function ($query) use ($negate) { - if ($negate) { - $query->whereNull('hidden_at')->where('comment_count', '>', 0); - } else { - $query->whereNotNull('hidden_at')->orWhere('comment_count', 0); - } - }); - } -} diff --git a/src/Extend/Filter.php b/src/Extend/Filter.php new file mode 100644 index 000000000..49f54c541 --- /dev/null +++ b/src/Extend/Filter.php @@ -0,0 +1,74 @@ +filtererClass = $filtererClass; + } + + /** + * Add a filter to run when the filtererClass is filtered. + * + * @param string $filterClass: The ::class attribute of the filter you are adding. + */ + public function addFilter(string $filterClass) + { + $this->filters[] = $filterClass; + + return $this; + } + + /** + * Add a callback through which to run all filter queries after filters have been applied. + * + * @param callable|string $callback + * + * The callback can be a closure or an invokable class, and should accept: + * - Flarum\Filter\FilterState $filter + * - Flarum\Search\SearchCriteria $criteria + */ + public function addFilterMutator($callback) + { + $this->filterMutators[] = $callback; + + return $this; + } + + public function extend(Container $container, Extension $extension = null) + { + $container->extend('flarum.filter.filters', function ($originalFilters) { + foreach ($this->filters as $filter) { + $originalFilters[$this->filtererClass][] = $filter; + } + + return $originalFilters; + }); + $container->extend('flarum.filter.filter_mutators', function ($originalMutators) { + foreach ($this->filterMutators as $mutator) { + $originalMutators[$this->filtererClass][] = $mutator; + } + + return $originalMutators; + }); + } +} diff --git a/src/Extend/SimpleFlarumSearch.php b/src/Extend/SimpleFlarumSearch.php index 6f308a506..678ed6754 100644 --- a/src/Extend/SimpleFlarumSearch.php +++ b/src/Extend/SimpleFlarumSearch.php @@ -60,7 +60,7 @@ class SimpleFlarumSearch implements ExtenderInterface * @param callable|string $callback * * The callback can be a closure or an invokable class, and should accept: - * - Flarum\Search\AbstractSearch $search + * - Flarum\Search\SearchState $search * - Flarum\Search\SearchCriteria $criteria */ public function addSearchMutator($callback) diff --git a/src/Filter/AbstractFilterer.php b/src/Filter/AbstractFilterer.php new file mode 100644 index 000000000..c7ddc0542 --- /dev/null +++ b/src/Filter/AbstractFilterer.php @@ -0,0 +1,86 @@ +filters = $filters; + $this->filterMutators = $filterMutators; + } + + abstract protected function getQuery(User $actor): Builder; + + /** + * @param SearchCriteria $criteria + * @param mixed|null $limit + * @param int $offset + * + * @return SearchResults + * @throws InvalidArgumentException + */ + public function filter(SearchCriteria $criteria, int $limit = null, int $offset = 0): SearchResults + { + $actor = $criteria->actor; + + $query = $this->getQuery($actor); + + $filterState = new FilterState($query->getQuery(), $actor); + + foreach ($criteria->query as $filterKey => $filterValue) { + $negate = false; + if (substr($filterKey, 0, 1) == '-') { + $negate = true; + $filterKey = substr($filterKey, 1); + } + foreach (Arr::get($this->filters, $filterKey, []) as $filter) { + $filter->filter($filterState, $filterValue, $negate); + } + } + + $this->applySort($filterState, $criteria->sort); + $this->applyOffset($filterState, $offset); + $this->applyLimit($filterState, $limit + 1); + + foreach ($this->filterMutators as $mutator) { + $mutator($query, $actor, $criteria->query, $criteria->sort); + } + + // Execute the filter query and retrieve the results. We get one more + // results than the user asked for, so that we can say if there are more + // results. If there are, we will get rid of that extra result. + $results = $query->get(); + + if ($areMoreResults = $limit > 0 && $results->count() > $limit) { + $results->pop(); + } + + return new SearchResults($results, $areMoreResults); + } +} diff --git a/src/Filter/FilterInterface.php b/src/Filter/FilterInterface.php new file mode 100644 index 000000000..a5853c4bd --- /dev/null +++ b/src/Filter/FilterInterface.php @@ -0,0 +1,26 @@ +app->singleton('flarum.filter.filters', function () { + return [ + DiscussionFilterer::class => [ + AuthorFilterGambit::class, + CreatedFilterGambit::class, + HiddenFilterGambit::class, + UnreadFilterGambit::class, + ], + UserFilterer::class => [ + EmailFilterGambit::class, + GroupFilterGambit::class, + ] + ]; + }); + + $this->app->singleton('flarum.filter.filter_mutators', function () { + return []; + }); + } + + public function boot() + { + // We can resolve the filter mutators in the when->needs->give callback, + // but we need to resolve at least one regardless so we know which + // filterers we need to register filters for. + $filters = $this->app->make('flarum.filter.filters'); + + foreach ($filters as $filterer => $filterClasses) { + $this->app + ->when($filterer) + ->needs('$filters') + ->give(function () use ($filterClasses) { + $compiled = []; + + foreach ($filterClasses as $filterClass) { + $filter = $this->app->make($filterClass); + $compiled[$filter->getFilterKey()][] = $filter; + } + + return $compiled; + }); + + $this->app + ->when($filterer) + ->needs('$filterMutators') + ->give(function () use ($filterer) { + return array_map(function ($filterMutatorClass) { + return ContainerUtil::wrapCallback($filterMutatorClass, $this->app); + }, Arr::get($this->app->make('flarum.filter.filter_mutators'), $filterer, [])); + }); + } + } +} diff --git a/src/Filter/FilterState.php b/src/Filter/FilterState.php new file mode 100644 index 000000000..cc5a4a259 --- /dev/null +++ b/src/Filter/FilterState.php @@ -0,0 +1,87 @@ +query = $query; + $this->actor = $actor; + $this->defaultSort = $defaultSort; + } + + /** + * Get the query builder for the search results query. + * + * @return Builder + */ + public function getQuery() + { + return $this->query; + } + + /** + * Get the user who is performing the search. + * + * @return User + */ + public function getActor() + { + return $this->actor; + } + + /** + * Get the default sort order for the search. + * + * @return array + */ + public function getDefaultSort() + { + return $this->defaultSort; + } + + /** + * Set the default sort order for the search. This will only be applied if + * a sort order has not been specified in the search criteria. + * + * @param mixed $defaultSort An array of sort-order pairs, where the column + * is the key, and the order is the value. The order may be 'asc', + * 'desc', or an array of IDs to order by. + * Alternatively, a callable may be used. + * @return mixed + */ + public function setDefaultSort($defaultSort) + { + $this->defaultSort = $defaultSort; + } +} diff --git a/src/Foundation/InstalledSite.php b/src/Foundation/InstalledSite.php index 8e944e827..ec52fb9cc 100644 --- a/src/Foundation/InstalledSite.php +++ b/src/Foundation/InstalledSite.php @@ -16,6 +16,7 @@ use Flarum\Console\ConsoleServiceProvider; use Flarum\Database\DatabaseServiceProvider; use Flarum\Discussion\DiscussionServiceProvider; use Flarum\Extension\ExtensionServiceProvider; +use Flarum\Filter\FilterServiceProvider; use Flarum\Formatter\FormatterServiceProvider; use Flarum\Forum\ForumServiceProvider; use Flarum\Frontend\FrontendServiceProvider; @@ -117,6 +118,7 @@ class InstalledSite implements SiteInterface $laravel->register(ExtensionServiceProvider::class); $laravel->register(ErrorServiceProvider::class); $laravel->register(FilesystemServiceProvider::class); + $laravel->register(FilterServiceProvider::class); $laravel->register(FormatterServiceProvider::class); $laravel->register(ForumServiceProvider::class); $laravel->register(FrontendServiceProvider::class); diff --git a/src/Search/AbstractRegexGambit.php b/src/Search/AbstractRegexGambit.php index 204b05c32..c1dd0fc2c 100644 --- a/src/Search/AbstractRegexGambit.php +++ b/src/Search/AbstractRegexGambit.php @@ -13,15 +13,15 @@ abstract class AbstractRegexGambit implements GambitInterface { /** * The regex pattern to match the bit against. - * - * @var string */ - protected $pattern; + protected function getGambitPattern() + { + } /** * {@inheritdoc} */ - public function apply(AbstractSearch $search, $bit) + public function apply(SearchState $search, $bit) { if ($matches = $this->match($bit)) { list($negate) = array_splice($matches, 1, 1); @@ -40,7 +40,8 @@ abstract class AbstractRegexGambit implements GambitInterface */ protected function match($bit) { - if (preg_match('/^(-?)'.$this->pattern.'$/i', $bit, $matches)) { + // @deprecated, remove use of $this->pattern during beta 17. + if (preg_match('/^(-?)'.($this->pattern ?? $this->getGambitPattern()).'$/i', $bit, $matches)) { return $matches; } } @@ -48,11 +49,12 @@ abstract class AbstractRegexGambit implements GambitInterface /** * Apply conditions to the search, given that the gambit was matched. * - * @param AbstractSearch $search The search object. + * @param SearchState $search The search object. * @param array $matches An array of matches from the search bit. * @param bool $negate Whether or not the bit was negated, and thus whether * or not the conditions should be negated. * @return mixed */ - abstract protected function conditions(AbstractSearch $search, array $matches, $negate); + // Uncomment for beta 17 + // abstract protected function conditions(SearchState $search, array $matches, $negate); } diff --git a/src/Search/AbstractSearch.php b/src/Search/AbstractSearch.php index 1de7dcac9..adac8ceb2 100644 --- a/src/Search/AbstractSearch.php +++ b/src/Search/AbstractSearch.php @@ -9,90 +9,19 @@ namespace Flarum\Search; -use Flarum\User\User; -use Illuminate\Database\Query\Builder; +use Flarum\Filter\FilterState; /** - * An object which represents the internal state of a generic search: - * the search query, the user performing the search, the fallback sort order, - * and a log of which gambits have been used. + * @deprecated, use SearchState instead. + * These methods should be transferred over to SearchState in beta 17. */ -abstract class AbstractSearch +class AbstractSearch extends FilterState { - /** - * @var Builder - */ - protected $query; - - /** - * @var User - */ - protected $actor; - - /** - * @var array - */ - protected $defaultSort = []; - /** * @var GambitInterface[] */ protected $activeGambits = []; - /** - * @param Builder $query - * @param User $actor - */ - public function __construct(Builder $query, User $actor) - { - $this->query = $query; - $this->actor = $actor; - } - - /** - * Get the query builder for the search results query. - * - * @return Builder - */ - public function getQuery() - { - return $this->query; - } - - /** - * Get the user who is performing the search. - * - * @return User - */ - public function getActor() - { - return $this->actor; - } - - /** - * Get the default sort order for the search. - * - * @return array - */ - public function getDefaultSort() - { - return $this->defaultSort; - } - - /** - * Set the default sort order for the search. This will only be applied if - * a sort order has not been specified in the search criteria. - * - * @param mixed $defaultSort An array of sort-order pairs, where the column - * is the key, and the order is the value. The order may be 'asc', - * 'desc', or an array of IDs to order by. - * @return mixed - */ - public function setDefaultSort($defaultSort) - { - $this->defaultSort = $defaultSort; - } - /** * Get a list of the gambits that are active in this search. * diff --git a/src/Search/AbstractSearcher.php b/src/Search/AbstractSearcher.php index 9ccb33d59..58de86243 100644 --- a/src/Search/AbstractSearcher.php +++ b/src/Search/AbstractSearcher.php @@ -34,9 +34,7 @@ abstract class AbstractSearcher abstract protected function getQuery(User $actor): Builder; - abstract protected function getSearch(Builder $query, User $actor): AbstractSearch; - - protected function mutateSearch(AbstractSearch $search, SearchCriteria $criteria) + protected function mutateSearch(SearchState $search, SearchCriteria $criteria) { foreach ($this->searchMutators as $mutator) { $mutator($search, $criteria); @@ -49,16 +47,17 @@ abstract class AbstractSearcher * @param int $offset * * @return SearchResults + * @throws InvalidArgumentException */ - public function search(SearchCriteria $criteria, $limit = null, $offset = 0, array $load = []) + public function search(SearchCriteria $criteria, $limit = null, $offset = 0): SearchResults { $actor = $criteria->actor; $query = $this->getQuery($actor); - $search = $this->getSearch($query, $actor); + $search = new SearchState($query->getQuery(), $actor); - $this->gambits->apply($search, $criteria->query); + $this->gambits->apply($search, $criteria->query['q']); $this->applySort($search, $criteria->sort); $this->applyOffset($search, $offset); $this->applyLimit($search, $limit + 1); @@ -74,8 +73,6 @@ abstract class AbstractSearcher $results->pop(); } - $results->load($load); - return new SearchResults($results, $areMoreResults); } } diff --git a/src/Search/ApplySearchParametersTrait.php b/src/Search/ApplySearchParametersTrait.php index 0e5798743..2b77a5bd8 100644 --- a/src/Search/ApplySearchParametersTrait.php +++ b/src/Search/ApplySearchParametersTrait.php @@ -9,6 +9,7 @@ namespace Flarum\Search; +use Flarum\Filter\FilterState; use Illuminate\Support\Str; trait ApplySearchParametersTrait @@ -16,10 +17,10 @@ trait ApplySearchParametersTrait /** * Apply sort criteria to a discussion search. * - * @param AbstractSearch $search + * @param FilterState $search * @param array $sort */ - protected function applySort(AbstractSearch $search, array $sort = null) + protected function applySort(FilterState $search, array $sort = null) { $sort = $sort ?: $search->getDefaultSort(); @@ -39,10 +40,10 @@ trait ApplySearchParametersTrait } /** - * @param AbstractSearch $search + * @param FilterState $search * @param int $offset */ - protected function applyOffset(AbstractSearch $search, $offset) + protected function applyOffset(FilterState $search, $offset) { if ($offset > 0) { $search->getQuery()->skip($offset); @@ -50,10 +51,10 @@ trait ApplySearchParametersTrait } /** - * @param AbstractSearch $search + * @param FilterState $search * @param int|null $limit */ - protected function applyLimit(AbstractSearch $search, $limit) + protected function applyLimit(FilterState $search, $limit) { if ($limit > 0) { $search->getQuery()->take($limit); diff --git a/src/Search/GambitInterface.php b/src/Search/GambitInterface.php index 4a190a2c8..cbb691e63 100644 --- a/src/Search/GambitInterface.php +++ b/src/Search/GambitInterface.php @@ -14,9 +14,9 @@ interface GambitInterface /** * Apply conditions to the searcher for a bit of the search string. * - * @param AbstractSearch $search + * @param SearchState $search * @param string $bit The piece of the search string. * @return bool Whether or not the gambit was active for this bit. */ - public function apply(AbstractSearch $search, $bit); + public function apply(SearchState $search, $bit); } diff --git a/src/Search/GambitManager.php b/src/Search/GambitManager.php index 775af42b6..b9e5b0b0f 100644 --- a/src/Search/GambitManager.php +++ b/src/Search/GambitManager.php @@ -55,10 +55,10 @@ class GambitManager /** * Apply gambits to a search, given a search query. * - * @param AbstractSearch $search + * @param SearchState $search * @param string $query */ - public function apply(AbstractSearch $search, $query) + public function apply(SearchState $search, $query) { $query = $this->applyGambits($search, $query); @@ -89,11 +89,11 @@ class GambitManager } /** - * @param AbstractSearch $search + * @param SearchState $search * @param string $query * @return string */ - protected function applyGambits(AbstractSearch $search, $query) + protected function applyGambits(SearchState $search, $query) { $bits = $this->explode($query); @@ -121,10 +121,10 @@ class GambitManager } /** - * @param AbstractSearch $search + * @param SearchState $search * @param string $query */ - protected function applyFulltext(AbstractSearch $search, $query) + protected function applyFulltext(SearchState $search, $query) { if (! $this->fulltextGambit) { return; diff --git a/src/Search/SearchCriteria.php b/src/Search/SearchCriteria.php index 6e8dd6e36..30820dd67 100644 --- a/src/Search/SearchCriteria.php +++ b/src/Search/SearchCriteria.php @@ -13,22 +13,22 @@ use Flarum\User\User; /** * Represents the criteria that will determine the entire result set of a - * search. The limit and offset are not included because they only determine + * query. The limit and offset are not included because they only determine * which part of the entire result set will be returned. */ class SearchCriteria { /** - * The user performing the search. + * The user performing the query. * * @var User */ public $actor; /** - * The search query. + * Query params. * - * @var string + * @var array */ public $query; @@ -42,8 +42,8 @@ class SearchCriteria public $sort; /** - * @param User $actor The user performing the search. - * @param string $query The search query. + * @param User $actor The user performing the query. + * @param array $query The query params. * @param array $sort An array of sort-order pairs, where the column is the * key, and the order is the value. The order may be 'asc', 'desc', or * an array of IDs to order by. diff --git a/src/Search/SearchServiceProvider.php b/src/Search/SearchServiceProvider.php index fee38bf95..264958f92 100644 --- a/src/Search/SearchServiceProvider.php +++ b/src/Search/SearchServiceProvider.php @@ -9,19 +9,19 @@ namespace Flarum\Search; +use Flarum\Discussion\Filter\AuthorFilterGambit; +use Flarum\Discussion\Filter\CreatedFilterGambit; +use Flarum\Discussion\Filter\HiddenFilterGambit; +use Flarum\Discussion\Filter\UnreadFilterGambit; use Flarum\Discussion\Search\DiscussionSearcher; -use Flarum\Discussion\Search\Gambit\AuthorGambit; -use Flarum\Discussion\Search\Gambit\CreatedGambit; use Flarum\Discussion\Search\Gambit\FulltextGambit as DiscussionFulltextGambit; -use Flarum\Discussion\Search\Gambit\HiddenGambit; -use Flarum\Discussion\Search\Gambit\UnreadGambit; use Flarum\Event\ConfigureDiscussionGambits; use Flarum\Event\ConfigureUserGambits; use Flarum\Foundation\AbstractServiceProvider; use Flarum\Foundation\ContainerUtil; -use Flarum\User\Search\Gambit\EmailGambit; +use Flarum\User\Filter\EmailFilterGambit; +use Flarum\User\Filter\GroupFilterGambit; use Flarum\User\Search\Gambit\FulltextGambit as UserFulltextGambit; -use Flarum\User\Search\Gambit\GroupGambit; use Flarum\User\Search\UserSearcher; use Illuminate\Support\Arr; @@ -42,14 +42,14 @@ class SearchServiceProvider extends AbstractServiceProvider $this->app->singleton('flarum.simple_search.gambits', function () { return [ DiscussionSearcher::class => [ - AuthorGambit::class, - CreatedGambit::class, - HiddenGambit::class, - UnreadGambit::class + AuthorFilterGambit::class, + CreatedFilterGambit::class, + HiddenFilterGambit::class, + UnreadFilterGambit::class ], UserSearcher::class => [ - EmailGambit::class, - GroupGambit::class + EmailFilterGambit::class, + GroupFilterGambit::class ] ]; }); diff --git a/src/User/Search/UserSearch.php b/src/Search/SearchState.php similarity index 63% rename from src/User/Search/UserSearch.php rename to src/Search/SearchState.php index 37106e538..b57fbeee2 100644 --- a/src/User/Search/UserSearch.php +++ b/src/Search/SearchState.php @@ -7,10 +7,8 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\User\Search; +namespace Flarum\Search; -use Flarum\Search\AbstractSearch; - -class UserSearch extends AbstractSearch +class SearchState extends AbstractSearch { } diff --git a/src/User/Event/Searching.php b/src/User/Event/Searching.php index ffa0db36c..ff6debac7 100644 --- a/src/User/Event/Searching.php +++ b/src/User/Event/Searching.php @@ -10,7 +10,7 @@ namespace Flarum\User\Event; use Flarum\Search\SearchCriteria; -use Flarum\User\Search\UserSearch; +use Flarum\Search\SearchState; /** * @deprecated beta 16, remove beta 17 @@ -18,7 +18,7 @@ use Flarum\User\Search\UserSearch; class Searching { /** - * @var \Flarum\User\Search\UserSearch + * @var \Flarum\User\Search\SearchState */ public $search; @@ -28,10 +28,10 @@ class Searching public $criteria; /** - * @param UserSearch $search + * @param SearchState $search * @param SearchCriteria $criteria */ - public function __construct(UserSearch $search, SearchCriteria $criteria) + public function __construct(SearchState $search, SearchCriteria $criteria) { $this->search = $search; $this->criteria = $criteria; diff --git a/src/User/Filter/EmailFilterGambit.php b/src/User/Filter/EmailFilterGambit.php new file mode 100644 index 000000000..3fc4ae9ab --- /dev/null +++ b/src/User/Filter/EmailFilterGambit.php @@ -0,0 +1,68 @@ +getActor()->hasPermission('user.edit')) { + return false; + } + + return parent::apply($search, $bit); + } + + /** + * {@inheritdoc} + */ + public function getGambitPattern() + { + return 'email:(.+)'; + } + + /** + * {@inheritdoc} + */ + protected function conditions(SearchState $search, array $matches, $negate) + { + $this->constrain($search->getQuery(), $matches[1], $negate); + } + + public function getFilterKey(): string + { + return 'email'; + } + + public function filter(FilterState $filterState, string $filterValue, bool $negate) + { + if (! $filterState->getActor()->hasPermission('user.edit')) { + return; + } + + $this->constrain($filterState->getQuery(), $filterValue, $negate); + } + + protected function constrain(Builder $query, $rawEmail, bool $negate) + { + $email = trim($rawEmail, '"'); + + $query->where('email', $negate ? '!=' : '=', $email); + } +} diff --git a/src/User/Filter/GroupFilterGambit.php b/src/User/Filter/GroupFilterGambit.php new file mode 100644 index 000000000..4d1c445f8 --- /dev/null +++ b/src/User/Filter/GroupFilterGambit.php @@ -0,0 +1,68 @@ +constrain($search->getQuery(), $search->getActor(), $matches[1], $negate); + } + + public function getFilterKey(): string + { + return 'group'; + } + + public function filter(FilterState $filterState, string $filterValue, bool $negate) + { + $this->constrain($filterState->getQuery(), $filterState->getActor(), $filterValue, $negate); + } + + protected function constrain(Builder $query, User $actor, string $rawQuery, bool $negate) + { + $groupIdentifiers = explode(',', trim($rawQuery, '"')); + + $groupQuery = Group::whereVisibleTo($actor); + + foreach ($groupIdentifiers as $identifier) { + if (is_numeric($identifier)) { + $groupQuery->orWhere('id', $identifier); + } else { + $groupQuery->orWhere('name_singular', $identifier)->orWhere('name_plural', $identifier); + } + } + + $userIds = $groupQuery->join('group_user', 'groups.id', 'group_user.group_id') + ->pluck('group_user.user_id') + ->all(); + + $query->whereIn('id', $userIds, 'and', $negate); + } +} diff --git a/src/User/Filter/UserFilterer.php b/src/User/Filter/UserFilterer.php new file mode 100644 index 000000000..418dca379 --- /dev/null +++ b/src/User/Filter/UserFilterer.php @@ -0,0 +1,40 @@ +users = $users; + } + + protected function getQuery(User $actor): Builder + { + return $this->users->query()->whereVisibleTo($actor); + } +} diff --git a/src/User/Search/Gambit/EmailGambit.php b/src/User/Search/Gambit/EmailGambit.php deleted file mode 100644 index f3ca088b6..000000000 --- a/src/User/Search/Gambit/EmailGambit.php +++ /dev/null @@ -1,63 +0,0 @@ -users = $users; - } - - /** - * {@inheritdoc} - */ - public function apply(AbstractSearch $search, $bit) - { - if (! $search->getActor()->hasPermission('user.edit')) { - return false; - } - - return parent::apply($search, $bit); - } - - /** - * {@inheritdoc} - */ - protected function conditions(AbstractSearch $search, array $matches, $negate) - { - if (! $search instanceof UserSearch) { - throw new LogicException('This gambit can only be applied on a UserSearch'); - } - - $email = trim($matches[1], '"'); - - $search->getQuery()->where('email', $negate ? '!=' : '=', $email); - } -} diff --git a/src/User/Search/Gambit/FulltextGambit.php b/src/User/Search/Gambit/FulltextGambit.php index 410d74810..0522e76d2 100644 --- a/src/User/Search/Gambit/FulltextGambit.php +++ b/src/User/Search/Gambit/FulltextGambit.php @@ -9,8 +9,8 @@ namespace Flarum\User\Search\Gambit; -use Flarum\Search\AbstractSearch; use Flarum\Search\GambitInterface; +use Flarum\Search\SearchState; use Flarum\User\UserRepository; class FulltextGambit implements GambitInterface @@ -43,7 +43,7 @@ class FulltextGambit implements GambitInterface /** * {@inheritdoc} */ - public function apply(AbstractSearch $search, $searchValue) + public function apply(SearchState $search, $searchValue) { $search->getQuery() ->whereIn( diff --git a/src/User/Search/Gambit/GroupGambit.php b/src/User/Search/Gambit/GroupGambit.php deleted file mode 100644 index 5e113e6df..000000000 --- a/src/User/Search/Gambit/GroupGambit.php +++ /dev/null @@ -1,77 +0,0 @@ -groups = $groups; - } - - /** - * {@inheritdoc} - */ - protected function conditions(AbstractSearch $search, array $matches, $negate) - { - if (! $search instanceof UserSearch) { - throw new LogicException('This gambit can only be applied on a UserSearch'); - } - - $groupIdentifiers = $this->extractGroupIdentifiers($matches); - - $groupQuery = Group::whereVisibleTo($search->getActor()); - - foreach ($groupIdentifiers as $identifier) { - if (is_numeric($identifier)) { - $groupQuery->orWhere('id', $identifier); - } else { - $groupQuery->orWhere('name_singular', $identifier)->orWhere('name_plural', $identifier); - } - } - - $userIds = $groupQuery->join('group_user', 'groups.id', 'group_user.group_id') - ->pluck('group_user.user_id') - ->all(); - - $search->getQuery()->whereIn('id', $userIds, 'and', $negate); - } - - /** - * Extract the group names from the pattern match. - * - * @param array $matches - * @return array - */ - protected function extractGroupIdentifiers(array $matches) - { - return explode(',', trim($matches[1], '"')); - } -} diff --git a/src/User/Search/UserSearcher.php b/src/User/Search/UserSearcher.php index cecd75651..d0ac09970 100644 --- a/src/User/Search/UserSearcher.php +++ b/src/User/Search/UserSearcher.php @@ -9,10 +9,10 @@ namespace Flarum\User\Search; -use Flarum\Search\AbstractSearch; use Flarum\Search\AbstractSearcher; use Flarum\Search\GambitManager; use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchState; use Flarum\User\Event\Searching; use Flarum\User\User; use Flarum\User\UserRepository; @@ -54,15 +54,10 @@ class UserSearcher extends AbstractSearcher return $this->users->query()->whereVisibleTo($actor); } - protected function getSearch(Builder $query, User $actor): AbstractSearch - { - return new UserSearch($query->getQuery(), $actor); - } - /** * @deprecated along with the Searching event, remove in Beta 17. */ - protected function mutateSearch(AbstractSearch $search, SearchCriteria $criteria) + protected function mutateSearch(SearchState $search, SearchCriteria $criteria) { parent::mutateSearch($search, $criteria); diff --git a/tests/integration/api/discussions/ListTest.php b/tests/integration/api/discussions/ListTest.php index 48e1ee949..9a16f411f 100644 --- a/tests/integration/api/discussions/ListTest.php +++ b/tests/integration/api/discussions/ListTest.php @@ -12,6 +12,8 @@ namespace Flarum\Tests\integration\api\discussions; use Carbon\Carbon; use Flarum\Tests\integration\RetrievesAuthorizedUsers; use Flarum\Tests\integration\TestCase; +use Flarum\User\User; +use Illuminate\Support\Arr; class ListTest extends TestCase { @@ -26,10 +28,16 @@ class ListTest extends TestCase $this->prepareDatabase([ 'discussions' => [ - ['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 1, 'comment_count' => 1], + ['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'last_posted_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], + ['id' => 2, 'title' => 'lightsail in title', 'created_at' => Carbon::createFromDate(1985, 5, 21)->toDateTimeString(), 'last_posted_at' => Carbon::createFromDate(1985, 5, 21)->toDateTimeString(), 'user_id' => 2, 'comment_count' => 1], + ['id' => 3, 'title' => 'not in title', 'created_at' => Carbon::createFromDate(1995, 5, 21)->toDateTimeString(), 'last_posted_at' => Carbon::createFromDate(1995, 5, 21)->toDateTimeString(), 'user_id' => 2, 'comment_count' => 1], + ['id' => 4, 'title' => 'hidden', 'created_at' => Carbon::createFromDate(2005, 5, 21)->toDateTimeString(), 'last_posted_at' => Carbon::createFromDate(2005, 5, 21)->toDateTimeString(), 'hidden_at' => Carbon::now()->toDateTimeString(), 'user_id' => 1, 'comment_count' => 1], ], 'posts' => [ - ['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '

foo bar

'], + ['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

'], + ['id' => 2, 'discussion_id' => 2, 'created_at' => Carbon::createFromDate(1985, 5, 21)->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '

not in text

'], + ['id' => 3, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1995, 5, 21)->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '

lightsail in text

'], + ['id' => 4, 'discussion_id' => 4, 'created_at' => Carbon::createFromDate(2005, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

lightsail in text

'], ], 'users' => [ $this->normalUser(), @@ -37,6 +45,16 @@ class ListTest extends TestCase ]); } + /** + * Mark some discussions, but not others, as read to test that filter/gambit. + */ + protected function read() + { + $user = User::find(2); + $user->marked_all_as_read_at = Carbon::createFromDate(1990, 0, 0)->toDateTimeString(); + $user->save(); + } + /** * @test */ @@ -49,22 +67,398 @@ class ListTest extends TestCase $this->assertEquals(200, $response->getStatusCode()); $data = json_decode($response->getBody()->getContents(), true); - $this->assertEquals(1, count($data['data'])); + $this->assertEquals(3, count($data['data'])); } /** * @test */ - public function can_search_for_author() + public function author_filter_works() { $response = $this->send( $this->request('GET', '/api/discussions') + ->withQueryParams([ + 'filter' => ['author' => 'normal'], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['2', '3'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function author_filter_works_negated() + { + $response = $this->send( + $this->request('GET', '/api/discussions') + ->withQueryParams([ + 'filter' => ['-author' => 'normal'], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['1'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function created_filter_works_with_date() + { + $response = $this->send( + $this->request('GET', '/api/discussions') + ->withQueryParams([ + 'filter' => ['created' => '1995-05-21'], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['3'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function created_filter_works_negated_with_date() + { + $response = $this->send( + $this->request('GET', '/api/discussions') + ->withQueryParams([ + 'filter' => ['-created' => '1995-05-21'], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['1', '2'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function created_filter_works_with_range() + { + $response = $this->send( + $this->request('GET', '/api/discussions') + ->withQueryParams([ + 'filter' => ['created' => '1980-05-21..2000-05-21'], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['2', '3'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function created_filter_works_negated_with_range() + { + $response = $this->send( + $this->request('GET', '/api/discussions') + ->withQueryParams([ + 'filter' => ['-created' => '1980-05-21..2000-05-21'], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['1'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function hidden_filter_works() + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 1]) + ->withQueryParams([ + 'filter' => ['hidden' => ''], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['4'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function hidden_filter_works_negated() + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 1]) + ->withQueryParams([ + 'filter' => ['-hidden' => ''], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['1', '2', '3'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function unread_filter_works() + { + $this->app(); + $this->read(); + + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) ->withQueryParams([ - 'filter' => ['q' => 'author:normal foo'], + 'filter' => ['unread' => ''], 'include' => 'mostRelevantPost', ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['3'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function unread_filter_works_when_negated() + { + $this->app(); + $this->read(); + + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams([ + 'filter' => ['-unread' => ''], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['1', '2'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function author_gambit_works() + { + $response = $this->send( + $this->request('GET', '/api/discussions') + ->withQueryParams([ + 'filter' => ['q' => 'author:normal'], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['2', '3'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function author_gambit_works_negated() + { + $response = $this->send( + $this->request('GET', '/api/discussions') + ->withQueryParams([ + 'filter' => ['q' => '-author:normal'], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['1'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function created_gambit_works_with_date() + { + $response = $this->send( + $this->request('GET', '/api/discussions') + ->withQueryParams([ + 'filter' => ['q' => 'created:1995-05-21'], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['3'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function created_gambit_works_negated_with_date() + { + $response = $this->send( + $this->request('GET', '/api/discussions') + ->withQueryParams([ + 'filter' => ['q' => '-created:1995-05-21'], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['1', '2'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function created_gambit_works_with_range() + { + $response = $this->send( + $this->request('GET', '/api/discussions') + ->withQueryParams([ + 'filter' => ['q' => 'created:1980-05-21..2000-05-21'], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['2', '3'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function created_gambit_works_negated_with_range() + { + $response = $this->send( + $this->request('GET', '/api/discussions') + ->withQueryParams([ + 'filter' => ['q' => '-created:1980-05-21..2000-05-21'], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['1'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function hidden_gambit_works() + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 1]) + ->withQueryParams([ + 'filter' => ['q' => 'is:hidden'], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['4'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function hidden_gambit_works_negated() + { + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 1]) + ->withQueryParams([ + 'filter' => ['q' => '-is:hidden'], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['1', '2', '3'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function unread_gambit_works() + { + $this->app(); + $this->read(); + + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams([ + 'filter' => ['q' => 'is:unread'], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['3'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); + } + + /** + * @test + */ + public function unread_gambit_works_when_negated() + { + $this->app(); + $this->read(); + + $response = $this->send( + $this->request('GET', '/api/discussions', ['authenticatedAs' => 2]) + ->withQueryParams([ + 'filter' => ['q' => '-is:unread'], + 'include' => 'mostRelevantPost', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $this->assertEquals(['1', '2'], Arr::pluck($data, 'id'), 'IDs do not match', 0.0, 10, true); } } diff --git a/tests/integration/api/users/ListTest.php b/tests/integration/api/users/ListTest.php index c2b05a941..72199e3b4 100644 --- a/tests/integration/api/users/ListTest.php +++ b/tests/integration/api/users/ListTest.php @@ -11,11 +11,26 @@ namespace Flarum\Tests\integration\api\users; use Flarum\Tests\integration\RetrievesAuthorizedUsers; use Flarum\Tests\integration\TestCase; +use Illuminate\Support\Arr; class ListTest extends TestCase { use RetrievesAuthorizedUsers; + /** + * @inheritDoc + */ + protected function setUp(): void + { + parent::setUp(); + + $this->prepareDatabase([ + 'users' => [ + $this->normalUser(), + ], + ]); + } + /** * @test */ @@ -59,4 +74,200 @@ class ListTest extends TestCase $this->assertEquals(200, $response->getStatusCode()); } + + /** + * @test + */ + public function shows_full_results_without_search_or_filter() + { + $response = $this->send( + $this->request('GET', '/api/users', [ + 'authenticatedAs' => 1, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode($response->getBody()->getContents(), true)['data']; + $this->assertEquals(['1', '2'], Arr::pluck($data, 'id')); + } + + /** + * @test + */ + public function group_filter_works() + { + $response = $this->send( + $this->request('GET', '/api/users', [ + 'authenticatedAs' => 1, + ])->withQueryParams([ + 'filter' => ['group' => '1'], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode($response->getBody()->getContents(), true)['data']; + $this->assertEquals(['1'], Arr::pluck($data, 'id')); + } + + /** + * @test + */ + public function group_filter_works_negated() + { + $response = $this->send( + $this->request('GET', '/api/users', [ + 'authenticatedAs' => 1, + ])->withQueryParams([ + 'filter' => ['-group' => '1'], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode($response->getBody()->getContents(), true)['data']; + $this->assertEquals(['2'], Arr::pluck($data, 'id')); + } + + /** + * @test + */ + public function email_filter_works() + { + $response = $this->send( + $this->request('GET', '/api/users', [ + 'authenticatedAs' => 1, + ])->withQueryParams([ + 'filter' => ['email' => 'admin@machine.local'], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode($response->getBody()->getContents(), true)['data']; + $this->assertEquals(['1'], Arr::pluck($data, 'id')); + } + + /** + * @test + */ + public function email_filter_works_negated() + { + $response = $this->send( + $this->request('GET', '/api/users', [ + 'authenticatedAs' => 1, + ])->withQueryParams([ + 'filter' => ['-email' => 'admin@machine.local'], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode($response->getBody()->getContents(), true)['data']; + $this->assertEquals(['2'], Arr::pluck($data, 'id')); + } + + /** + * @test + */ + public function email_filter_only_works_for_admin() + { + $response = $this->send( + $this->request('GET', '/api/users', [ + 'authenticatedAs' => 2, + ])->withQueryParams([ + 'filter' => ['email' => 'admin@machine.local'], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode($response->getBody()->getContents(), true)['data']; + $this->assertEquals(['1', '2'], Arr::pluck($data, 'id')); + } + + /** + * @test + */ + public function group_gambit_works() + { + $response = $this->send( + $this->request('GET', '/api/users', [ + 'authenticatedAs' => 1, + ])->withQueryParams([ + 'filter' => ['q' => 'group:1'], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode($response->getBody()->getContents(), true)['data']; + $this->assertEquals(['1'], Arr::pluck($data, 'id')); + } + + /** + * @test + */ + public function group_gambit_works_negated() + { + $response = $this->send( + $this->request('GET', '/api/users', [ + 'authenticatedAs' => 1, + ])->withQueryParams([ + 'filter' => ['q' => '-group:1'], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode($response->getBody()->getContents(), true)['data']; + $this->assertEquals(['2'], Arr::pluck($data, 'id')); + } + + /** + * @test + */ + public function email_gambit_works() + { + $response = $this->send( + $this->request('GET', '/api/users', [ + 'authenticatedAs' => 1, + ])->withQueryParams([ + 'filter' => ['q' => 'email:admin@machine.local'], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode($response->getBody()->getContents(), true)['data']; + $this->assertEquals(['1'], Arr::pluck($data, 'id')); + } + + /** + * @test + */ + public function email_gambit_works_negated() + { + $response = $this->send( + $this->request('GET', '/api/users', [ + 'authenticatedAs' => 1, + ])->withQueryParams([ + 'filter' => ['q' => '-email:admin@machine.local'], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode($response->getBody()->getContents(), true)['data']; + $this->assertEquals(['2'], Arr::pluck($data, 'id')); + } + + /** + * @test + */ + public function email_gambit_only_works_for_admin() + { + $response = $this->send( + $this->request('GET', '/api/users', [ + 'authenticatedAs' => 2, + ])->withQueryParams([ + 'filter' => ['q' => 'email:admin@machine.local'], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode($response->getBody()->getContents(), true)['data']; + $this->assertEquals([], Arr::pluck($data, 'id')); + } } diff --git a/tests/integration/extenders/FilterTest.php b/tests/integration/extenders/FilterTest.php new file mode 100644 index 000000000..68b81bd7e --- /dev/null +++ b/tests/integration/extenders/FilterTest.php @@ -0,0 +1,134 @@ +prepareDatabase([ + 'discussions' => [ + ['id' => 1, 'title' => 'DISCUSSION 1', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 1, 'comment_count' => 1], + ['id' => 2, 'title' => 'DISCUSSION 2', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 2, 'comment_count' => 1], + ], + 'posts' => [ + ['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '

foo bar

'], + ['id' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '

foo bar not the same

'], + ], + 'users' => [ + $this->normalUser(), + ], + ]); + } + + public function filterDiscussions($filters, $limit = null) + { + $response = $this->send( + $this->request('GET', '/api/discussions', [ + 'authenticatedAs' => 1, + ])->withQueryParams([ + 'filter' => $filters, + 'include' => 'mostRelevantPost', + ]) + ); + + return json_decode($response->getBody()->getContents(), true)['data']; + } + + /** + * @test + */ + public function works_as_expected_with_no_modifications() + { + $this->prepDb(); + + $searchForAll = json_encode($this->filterDiscussions([], 5)); + $this->assertContains('DISCUSSION 1', $searchForAll); + $this->assertContains('DISCUSSION 2', $searchForAll); + } + + /** + * @test + */ + public function custom_filter_has_effect_if_added() + { + $this->extend((new Extend\Filter(DiscussionFilterer::class))->addFilter(NoResultFilter::class)); + + $this->prepDb(); + + $withResultSearch = json_encode($this->filterDiscussions(['noResult' => 0], 5)); + $this->assertContains('DISCUSSION 1', $withResultSearch); + $this->assertContains('DISCUSSION 2', $withResultSearch); + $this->assertEquals([], $this->filterDiscussions(['noResult' => 1], 5)); + } + + /** + * @test + */ + public function filter_mutator_has_effect_if_added() + { + $this->extend((new Extend\Filter(DiscussionFilterer::class))->addFilterMutator(function ($query, $actor, $filters, $sort) { + $query->getQuery()->whereRaw('1=0'); + })); + + $this->prepDb(); + + $this->assertEquals([], $this->filterDiscussions([], 5)); + } + + /** + * @test + */ + public function filter_mutator_has_effect_if_added_with_invokable_class() + { + $this->extend((new Extend\Filter(DiscussionFilterer::class))->addFilterMutator(CustomFilterMutator::class)); + + $this->prepDb(); + + $this->assertEquals([], $this->filterDiscussions([], 5)); + } +} + +class NoResultFilter implements FilterInterface +{ + public function getFilterKey(): string + { + return 'noResult'; + } + + /** + * {@inheritdoc} + */ + public function filter(FilterState $filterState, string $filterValue, bool $negate) + { + if ($filterValue) { + $filterState->getQuery() + ->whereRaw('0=1'); + } + } +} + +class CustomFilterMutator +{ + public function __invoke($query, $actor, $filters, $sort) + { + $query->getQuery()->whereRaw('1=0'); + } +} diff --git a/tests/integration/extenders/SimpleFlarumSearchTest.php b/tests/integration/extenders/SimpleFlarumSearchTest.php index 2a874cf76..52d8dfa9a 100644 --- a/tests/integration/extenders/SimpleFlarumSearchTest.php +++ b/tests/integration/extenders/SimpleFlarumSearchTest.php @@ -13,9 +13,9 @@ use Carbon\Carbon; use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Extend; use Flarum\Search\AbstractRegexGambit; -use Flarum\Search\AbstractSearch; use Flarum\Search\GambitInterface; use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchState; use Flarum\Tests\integration\RetrievesAuthorizedUsers; use Flarum\Tests\integration\TestCase; use Flarum\User\User; @@ -64,7 +64,7 @@ class SimpleFlarumSearchTest extends TestCase $actor = User::find(1); - $criteria = new SearchCriteria($actor, $query); + $criteria = new SearchCriteria($actor, ['q' => $query]); return $this->app()->getContainer()->make(DiscussionSearcher::class)->search($criteria, $limit)->getResults(); } @@ -142,7 +142,7 @@ class NoResultFullTextGambit implements GambitInterface /** * {@inheritdoc} */ - public function apply(AbstractSearch $search, $searchValue) + public function apply(SearchState $search, $searchValue) { $search->getQuery() ->whereRaw('0=1'); @@ -151,12 +151,18 @@ class NoResultFullTextGambit implements GambitInterface class NoResultFilterGambit extends AbstractRegexGambit { - protected $pattern = 'noResult:(.+)'; + /** + * {@inheritdoc} + */ + public function getGambitPattern() + { + return 'noResult:(.+)'; + } /** * {@inheritdoc} */ - public function conditions(AbstractSearch $search, array $matches, $negate) + public function conditions(SearchState $search, array $matches, $negate) { $noResults = trim($matches[1], ' '); if ($noResults == '1') {