Search Filter Split, Use Same Controller (#2454)

This commit is contained in:
Alexander Skvortsov 2021-02-24 11:17:40 -05:00 committed by GitHub
parent 2b69deef72
commit 87e58f390a
42 changed files with 1663 additions and 535 deletions

View File

@ -11,10 +11,10 @@ namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\DiscussionSerializer; use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Discussion\Discussion; use Flarum\Discussion\Discussion;
use Flarum\Discussion\Filter\DiscussionFilterer;
use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Discussion\Search\DiscussionSearcher;
use Flarum\Http\UrlGenerator; use Flarum\Http\UrlGenerator;
use Flarum\Search\SearchCriteria; use Flarum\Search\SearchCriteria;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document; use Tobscure\JsonApi\Document;
@ -43,11 +43,21 @@ class ListDiscussionsController extends AbstractListController
'lastPost' 'lastPost'
]; ];
/**
* {@inheritDoc}
*/
public $sort = ['lastPostedAt' => 'desc'];
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public $sortFields = ['lastPostedAt', 'commentCount', 'createdAt']; public $sortFields = ['lastPostedAt', 'commentCount', 'createdAt'];
/**
* @var DiscussionFilterer
*/
protected $filterer;
/** /**
* @var DiscussionSearcher * @var DiscussionSearcher
*/ */
@ -59,11 +69,13 @@ class ListDiscussionsController extends AbstractListController
protected $url; protected $url;
/** /**
* @param DiscussionFilterer $filterer
* @param DiscussionSearcher $searcher * @param DiscussionSearcher $searcher
* @param UrlGenerator $url * @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->searcher = $searcher;
$this->url = $url; $this->url = $url;
} }
@ -74,16 +86,19 @@ class ListDiscussionsController extends AbstractListController
protected function data(ServerRequestInterface $request, Document $document) protected function data(ServerRequestInterface $request, Document $document)
{ {
$actor = $request->getAttribute('actor'); $actor = $request->getAttribute('actor');
$query = Arr::get($this->extractFilter($request), 'q'); $filters = $this->extractFilter($request);
$sort = $this->extractSort($request); $sort = $this->extractSort($request);
$criteria = new SearchCriteria($actor, $query, $sort);
$limit = $this->extractLimit($request); $limit = $this->extractLimit($request);
$offset = $this->extractOffset($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( $document->addPaginationLinks(
$this->url->to('api')->route('discussions.index'), $this->url->to('api')->route('discussions.index'),
@ -95,9 +110,9 @@ class ListDiscussionsController extends AbstractListController
Discussion::setStateUser($actor); 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 ($results as $discussion) {
foreach ($relations as $relation) { foreach ($relations as $relation) {
if ($discussion->$relation) { if ($discussion->$relation) {

View File

@ -12,8 +12,8 @@ namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\UserSerializer; use Flarum\Api\Serializer\UserSerializer;
use Flarum\Http\UrlGenerator; use Flarum\Http\UrlGenerator;
use Flarum\Search\SearchCriteria; use Flarum\Search\SearchCriteria;
use Flarum\User\Filter\UserFilterer;
use Flarum\User\Search\UserSearcher; use Flarum\User\Search\UserSearcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document; use Tobscure\JsonApi\Document;
@ -40,6 +40,11 @@ class ListUsersController extends AbstractListController
'joinedAt' 'joinedAt'
]; ];
/**
* @var UserFilterer
*/
protected $filterer;
/** /**
* @var UserSearcher * @var UserSearcher
*/ */
@ -51,11 +56,13 @@ class ListUsersController extends AbstractListController
protected $url; protected $url;
/** /**
* @param UserFilterer $filterer
* @param UserSearcher $searcher * @param UserSearcher $searcher
* @param UrlGenerator $url * @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->searcher = $searcher;
$this->url = $url; $this->url = $url;
} }
@ -69,16 +76,19 @@ class ListUsersController extends AbstractListController
$actor->assertCan('viewUserList'); $actor->assertCan('viewUserList');
$query = Arr::get($this->extractFilter($request), 'q'); $filters = $this->extractFilter($request);
$sort = $this->extractSort($request); $sort = $this->extractSort($request);
$criteria = new SearchCriteria($actor, $query, $sort);
$limit = $this->extractLimit($request); $limit = $this->extractLimit($request);
$offset = $this->extractOffset($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( $document->addPaginationLinks(
$this->url->to('api')->route('users.index'), $this->url->to('api')->route('users.index'),
@ -88,6 +98,6 @@ class ListUsersController extends AbstractListController
$results->areMoreResults() ? null : 0 $results->areMoreResults() ? null : 0
); );
return $results->getResults(); return $results->getResults()->load($include);
} }
} }

View File

@ -9,8 +9,8 @@
namespace Flarum\Discussion\Event; namespace Flarum\Discussion\Event;
use Flarum\Discussion\Search\DiscussionSearch;
use Flarum\Search\SearchCriteria; use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchState;
/** /**
* @deprecated beta 16, remove beta 17 * @deprecated beta 16, remove beta 17
@ -18,7 +18,7 @@ use Flarum\Search\SearchCriteria;
class Searching class Searching
{ {
/** /**
* @var DiscussionSearch * @var SearchState
*/ */
public $search; public $search;
@ -28,10 +28,10 @@ class Searching
public $criteria; public $criteria;
/** /**
* @param DiscussionSearch $search * @param SearchState $search
* @param \Flarum\Search\SearchCriteria $criteria * @param \Flarum\Search\SearchCriteria $criteria
*/ */
public function __construct(DiscussionSearch $search, SearchCriteria $criteria) public function __construct(SearchState $search, SearchCriteria $criteria)
{ {
$this->search = $search; $this->search = $search;
$this->criteria = $criteria; $this->criteria = $criteria;

View File

@ -0,0 +1,72 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Discussion\Filter;
use Flarum\Filter\FilterInterface;
use Flarum\Filter\FilterState;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\SearchState;
use Flarum\User\UserRepository;
use Illuminate\Database\Query\Builder;
class AuthorFilterGambit extends AbstractRegexGambit implements FilterInterface
{
/**
* @var \Flarum\User\UserRepository
*/
protected $users;
/**
* @param \Flarum\User\UserRepository $users
*/
public function __construct(UserRepository $users)
{
$this->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);
}
}

View File

@ -0,0 +1,61 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Discussion\Filter;
use Flarum\Filter\FilterInterface;
use Flarum\Filter\FilterState;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\SearchState;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
class CreatedFilterGambit extends AbstractRegexGambit implements FilterInterface
{
/**
* {@inheritdoc}
*/
public function getGambitPattern()
{
return 'created:(\d{4}\-\d\d\-\d\d)(\.\.(\d{4}\-\d\d\-\d\d))?';
}
/**
* {@inheritdoc}
*/
protected function conditions(SearchState $search, array $matches, $negate)
{
$this->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);
}
}
}

View File

@ -0,0 +1,40 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Discussion\Filter;
use Flarum\Discussion\DiscussionRepository;
use Flarum\Filter\AbstractFilterer;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class DiscussionFilterer extends AbstractFilterer
{
/**
* @var DiscussionRepository
*/
protected $discussions;
/**
* @param DiscussionRepository $discussions
* @param array $filters
* @param array $filterMutators
*/
public function __construct(DiscussionRepository $discussions, array $filters, array $filterMutators)
{
parent::__construct($filters, $filterMutators);
$this->discussions = $discussions;
}
protected function getQuery(User $actor): Builder
{
return $this->discussions->query()->select('discussions.*')->whereVisibleTo($actor);
}
}

View File

@ -0,0 +1,56 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Discussion\Filter;
use Flarum\Filter\FilterInterface;
use Flarum\Filter\FilterState;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\SearchState;
use Illuminate\Database\Query\Builder;
class HiddenFilterGambit extends AbstractRegexGambit implements FilterInterface
{
/**
* {@inheritdoc}
*/
public function getGambitPattern()
{
return 'is:hidden';
}
/**
* {@inheritdoc}
*/
protected function conditions(SearchState $search, array $matches, $negate)
{
$this->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);
}
});
}
}

View File

@ -7,21 +7,18 @@
* LICENSE file that was distributed with this source code. * 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\DiscussionRepository;
use Flarum\Discussion\Search\DiscussionSearch; use Flarum\Filter\FilterInterface;
use Flarum\Filter\FilterState;
use Flarum\Search\AbstractRegexGambit; use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\AbstractSearch; use Flarum\Search\SearchState;
use LogicException; 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 * @var \Flarum\Discussion\DiscussionRepository
*/ */
@ -38,18 +35,35 @@ class UnreadGambit extends AbstractRegexGambit
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected function conditions(AbstractSearch $search, array $matches, $negate) public function getGambitPattern()
{ {
if (! $search instanceof DiscussionSearch) { return 'is:unread';
throw new LogicException('This gambit can only be applied on a DiscussionSearch'); }
}
$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) { if ($actor->exists) {
$readIds = $this->discussions->getReadIds($actor); $readIds = $this->discussions->getReadIds($actor);
$search->getQuery()->where(function ($query) use ($readIds, $negate, $actor) { $query->where(function ($query) use ($readIds, $negate, $actor) {
if (! $negate) { if (! $negate) {
$query->whereNotIn('id', $readIds)->where('last_posted_at', '>', $actor->marked_all_as_read_at ?: 0); $query->whereNotIn('id', $readIds)->where('last_posted_at', '>', $actor->marked_all_as_read_at ?: 0);
} else { } else {

View File

@ -1,51 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Discussion\Search;
use Flarum\Search\AbstractSearch;
/**
* An object which represents the internal state of a search for discussions:
* the search query, the user performing the search, the fallback sort order,
* relevant post information, and a log of which gambits have been used.
*/
class DiscussionSearch extends AbstractSearch
{
/**
* {@inheritdoc}
*/
protected $defaultSort = ['lastPostedAt' => '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;
}
}

View File

@ -11,10 +11,10 @@ namespace Flarum\Discussion\Search;
use Flarum\Discussion\DiscussionRepository; use Flarum\Discussion\DiscussionRepository;
use Flarum\Discussion\Event\Searching; use Flarum\Discussion\Event\Searching;
use Flarum\Search\AbstractSearch;
use Flarum\Search\AbstractSearcher; use Flarum\Search\AbstractSearcher;
use Flarum\Search\GambitManager; use Flarum\Search\GambitManager;
use Flarum\Search\SearchCriteria; use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchState;
use Flarum\User\User; use Flarum\User\User;
use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@ -50,15 +50,10 @@ class DiscussionSearcher extends AbstractSearcher
return $this->discussions->query()->select('discussions.*')->whereVisibleTo($actor); 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. * @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); parent::mutateSearch($search, $criteria);

View File

@ -1,57 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Discussion\Search\Gambit;
use Flarum\Discussion\Search\DiscussionSearch;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\AbstractSearch;
use Flarum\User\UserRepository;
use LogicException;
class AuthorGambit extends AbstractRegexGambit
{
/**
* {@inheritdoc}
*/
protected $pattern = 'author:(.+)';
/**
* @var \Flarum\User\UserRepository
*/
protected $users;
/**
* @param \Flarum\User\UserRepository $users
*/
public function __construct(UserRepository $users)
{
$this->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);
}
}

View File

@ -1,43 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Discussion\Search\Gambit;
use Flarum\Discussion\Search\DiscussionSearch;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\AbstractSearch;
use LogicException;
class CreatedGambit extends AbstractRegexGambit
{
/**
* {@inheritdoc}
*/
protected $pattern = 'created:(\d{4}\-\d\d\-\d\d)(\.\.(\d{4}\-\d\d\-\d\d))?';
/**
* {@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');
}
// 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($matches[3])) {
$search->getQuery()->whereDate('created_at', $negate ? '!=' : '=', $matches[1]);
} else {
$search->getQuery()->whereBetween('created_at', [$matches[1], $matches[3]], 'and', $negate);
}
}
}

View File

@ -9,24 +9,18 @@
namespace Flarum\Discussion\Search\Gambit; namespace Flarum\Discussion\Search\Gambit;
use Flarum\Discussion\Search\DiscussionSearch;
use Flarum\Post\Post; use Flarum\Post\Post;
use Flarum\Search\AbstractSearch;
use Flarum\Search\GambitInterface; use Flarum\Search\GambitInterface;
use Flarum\Search\SearchState;
use Illuminate\Database\Query\Expression; use Illuminate\Database\Query\Expression;
use LogicException;
class FulltextGambit implements GambitInterface class FulltextGambit implements GambitInterface
{ {
/** /**
* {@inheritdoc} * {@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. // Replace all non-word characters with spaces.
// We do this to prevent MySQL fulltext search boolean mode from taking // 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 // effect: https://dev.mysql.com/doc/refman/5.7/en/fulltext-boolean.html

View File

@ -1,41 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Discussion\Search\Gambit;
use Flarum\Discussion\Search\DiscussionSearch;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\AbstractSearch;
use LogicException;
class HiddenGambit extends AbstractRegexGambit
{
/**
* {@inheritdoc}
*/
protected $pattern = 'is:hidden';
/**
* {@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');
}
$search->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);
}
});
}
}

View File

@ -0,0 +1,74 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Extend;
use Flarum\Extension\Extension;
use Illuminate\Contracts\Container\Container;
class Filter implements ExtenderInterface
{
private $filtererClass;
private $filters = [];
private $filterMutators = [];
/**
* @param string $filtererClass: The ::class attribute of the filterer to extend
*/
public function __construct($filtererClass)
{
$this->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;
});
}
}

View File

@ -60,7 +60,7 @@ class SimpleFlarumSearch implements ExtenderInterface
* @param callable|string $callback * @param callable|string $callback
* *
* The callback can be a closure or an invokable class, and should accept: * 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 * - Flarum\Search\SearchCriteria $criteria
*/ */
public function addSearchMutator($callback) public function addSearchMutator($callback)

View File

@ -0,0 +1,86 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Filter;
use Flarum\Search\ApplySearchParametersTrait;
use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchResults;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use InvalidArgumentException;
abstract class AbstractFilterer
{
use ApplySearchParametersTrait;
protected $filters;
protected $filterMutators;
/**
* @param array $filters
* @param array $filterMutators
*/
public function __construct(array $filters, array $filterMutators)
{
$this->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);
}
}

View File

@ -0,0 +1,26 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Filter;
interface FilterInterface
{
/**
* This filter will only be run when a query contains a filter param with this key.
*/
public function getFilterKey(): string;
/**
* Filters a query.
*
* @param FilterState $filter
* @param string $value The value of the requested filter
*/
public function filter(FilterState $filterState, string $filterValue, bool $negate);
}

View File

@ -0,0 +1,85 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Filter;
use Flarum\Discussion\Filter\AuthorFilterGambit;
use Flarum\Discussion\Filter\CreatedFilterGambit;
use Flarum\Discussion\Filter\DiscussionFilterer;
use Flarum\Discussion\Filter\HiddenFilterGambit;
use Flarum\Discussion\Filter\UnreadFilterGambit;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\ContainerUtil;
use Flarum\User\Filter\EmailFilterGambit;
use Flarum\User\Filter\GroupFilterGambit;
use Flarum\User\Filter\UserFilterer;
use Illuminate\Support\Arr;
class FilterServiceProvider extends AbstractServiceProvider
{
/**
* Register the service provider.
*
* @return void
*/
public function register()
{
$this->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, []));
});
}
}
}

View File

@ -0,0 +1,87 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Filter;
use Flarum\User\User;
use Illuminate\Database\Query\Builder;
class FilterState
{
/**
* @var Builder
*/
protected $query;
/**
* @var User
*/
protected $actor;
/**
* @var mixed
*/
protected $defaultSort = [];
/**
* @param Builder $query
* @param User $actor
*/
public function __construct(Builder $query, User $actor, $defaultSort = [])
{
$this->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;
}
}

View File

@ -16,6 +16,7 @@ use Flarum\Console\ConsoleServiceProvider;
use Flarum\Database\DatabaseServiceProvider; use Flarum\Database\DatabaseServiceProvider;
use Flarum\Discussion\DiscussionServiceProvider; use Flarum\Discussion\DiscussionServiceProvider;
use Flarum\Extension\ExtensionServiceProvider; use Flarum\Extension\ExtensionServiceProvider;
use Flarum\Filter\FilterServiceProvider;
use Flarum\Formatter\FormatterServiceProvider; use Flarum\Formatter\FormatterServiceProvider;
use Flarum\Forum\ForumServiceProvider; use Flarum\Forum\ForumServiceProvider;
use Flarum\Frontend\FrontendServiceProvider; use Flarum\Frontend\FrontendServiceProvider;
@ -117,6 +118,7 @@ class InstalledSite implements SiteInterface
$laravel->register(ExtensionServiceProvider::class); $laravel->register(ExtensionServiceProvider::class);
$laravel->register(ErrorServiceProvider::class); $laravel->register(ErrorServiceProvider::class);
$laravel->register(FilesystemServiceProvider::class); $laravel->register(FilesystemServiceProvider::class);
$laravel->register(FilterServiceProvider::class);
$laravel->register(FormatterServiceProvider::class); $laravel->register(FormatterServiceProvider::class);
$laravel->register(ForumServiceProvider::class); $laravel->register(ForumServiceProvider::class);
$laravel->register(FrontendServiceProvider::class); $laravel->register(FrontendServiceProvider::class);

View File

@ -13,15 +13,15 @@ abstract class AbstractRegexGambit implements GambitInterface
{ {
/** /**
* The regex pattern to match the bit against. * The regex pattern to match the bit against.
*
* @var string
*/ */
protected $pattern; protected function getGambitPattern()
{
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function apply(AbstractSearch $search, $bit) public function apply(SearchState $search, $bit)
{ {
if ($matches = $this->match($bit)) { if ($matches = $this->match($bit)) {
list($negate) = array_splice($matches, 1, 1); list($negate) = array_splice($matches, 1, 1);
@ -40,7 +40,8 @@ abstract class AbstractRegexGambit implements GambitInterface
*/ */
protected function match($bit) 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; return $matches;
} }
} }
@ -48,11 +49,12 @@ abstract class AbstractRegexGambit implements GambitInterface
/** /**
* Apply conditions to the search, given that the gambit was matched. * 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 array $matches An array of matches from the search bit.
* @param bool $negate Whether or not the bit was negated, and thus whether * @param bool $negate Whether or not the bit was negated, and thus whether
* or not the conditions should be negated. * or not the conditions should be negated.
* @return mixed * @return mixed
*/ */
abstract protected function conditions(AbstractSearch $search, array $matches, $negate); // Uncomment for beta 17
// abstract protected function conditions(SearchState $search, array $matches, $negate);
} }

View File

@ -9,90 +9,19 @@
namespace Flarum\Search; namespace Flarum\Search;
use Flarum\User\User; use Flarum\Filter\FilterState;
use Illuminate\Database\Query\Builder;
/** /**
* An object which represents the internal state of a generic search: * @deprecated, use SearchState instead.
* the search query, the user performing the search, the fallback sort order, * These methods should be transferred over to SearchState in beta 17.
* and a log of which gambits have been used.
*/ */
abstract class AbstractSearch class AbstractSearch extends FilterState
{ {
/**
* @var Builder
*/
protected $query;
/**
* @var User
*/
protected $actor;
/**
* @var array
*/
protected $defaultSort = [];
/** /**
* @var GambitInterface[] * @var GambitInterface[]
*/ */
protected $activeGambits = []; 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. * Get a list of the gambits that are active in this search.
* *

View File

@ -34,9 +34,7 @@ abstract class AbstractSearcher
abstract protected function getQuery(User $actor): Builder; abstract protected function getQuery(User $actor): Builder;
abstract protected function getSearch(Builder $query, User $actor): AbstractSearch; protected function mutateSearch(SearchState $search, SearchCriteria $criteria)
protected function mutateSearch(AbstractSearch $search, SearchCriteria $criteria)
{ {
foreach ($this->searchMutators as $mutator) { foreach ($this->searchMutators as $mutator) {
$mutator($search, $criteria); $mutator($search, $criteria);
@ -49,16 +47,17 @@ abstract class AbstractSearcher
* @param int $offset * @param int $offset
* *
* @return SearchResults * @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; $actor = $criteria->actor;
$query = $this->getQuery($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->applySort($search, $criteria->sort);
$this->applyOffset($search, $offset); $this->applyOffset($search, $offset);
$this->applyLimit($search, $limit + 1); $this->applyLimit($search, $limit + 1);
@ -74,8 +73,6 @@ abstract class AbstractSearcher
$results->pop(); $results->pop();
} }
$results->load($load);
return new SearchResults($results, $areMoreResults); return new SearchResults($results, $areMoreResults);
} }
} }

View File

@ -9,6 +9,7 @@
namespace Flarum\Search; namespace Flarum\Search;
use Flarum\Filter\FilterState;
use Illuminate\Support\Str; use Illuminate\Support\Str;
trait ApplySearchParametersTrait trait ApplySearchParametersTrait
@ -16,10 +17,10 @@ trait ApplySearchParametersTrait
/** /**
* Apply sort criteria to a discussion search. * Apply sort criteria to a discussion search.
* *
* @param AbstractSearch $search * @param FilterState $search
* @param array $sort * @param array $sort
*/ */
protected function applySort(AbstractSearch $search, array $sort = null) protected function applySort(FilterState $search, array $sort = null)
{ {
$sort = $sort ?: $search->getDefaultSort(); $sort = $sort ?: $search->getDefaultSort();
@ -39,10 +40,10 @@ trait ApplySearchParametersTrait
} }
/** /**
* @param AbstractSearch $search * @param FilterState $search
* @param int $offset * @param int $offset
*/ */
protected function applyOffset(AbstractSearch $search, $offset) protected function applyOffset(FilterState $search, $offset)
{ {
if ($offset > 0) { if ($offset > 0) {
$search->getQuery()->skip($offset); $search->getQuery()->skip($offset);
@ -50,10 +51,10 @@ trait ApplySearchParametersTrait
} }
/** /**
* @param AbstractSearch $search * @param FilterState $search
* @param int|null $limit * @param int|null $limit
*/ */
protected function applyLimit(AbstractSearch $search, $limit) protected function applyLimit(FilterState $search, $limit)
{ {
if ($limit > 0) { if ($limit > 0) {
$search->getQuery()->take($limit); $search->getQuery()->take($limit);

View File

@ -14,9 +14,9 @@ interface GambitInterface
/** /**
* Apply conditions to the searcher for a bit of the search string. * 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. * @param string $bit The piece of the search string.
* @return bool Whether or not the gambit was active for this bit. * @return bool Whether or not the gambit was active for this bit.
*/ */
public function apply(AbstractSearch $search, $bit); public function apply(SearchState $search, $bit);
} }

View File

@ -55,10 +55,10 @@ class GambitManager
/** /**
* Apply gambits to a search, given a search query. * Apply gambits to a search, given a search query.
* *
* @param AbstractSearch $search * @param SearchState $search
* @param string $query * @param string $query
*/ */
public function apply(AbstractSearch $search, $query) public function apply(SearchState $search, $query)
{ {
$query = $this->applyGambits($search, $query); $query = $this->applyGambits($search, $query);
@ -89,11 +89,11 @@ class GambitManager
} }
/** /**
* @param AbstractSearch $search * @param SearchState $search
* @param string $query * @param string $query
* @return string * @return string
*/ */
protected function applyGambits(AbstractSearch $search, $query) protected function applyGambits(SearchState $search, $query)
{ {
$bits = $this->explode($query); $bits = $this->explode($query);
@ -121,10 +121,10 @@ class GambitManager
} }
/** /**
* @param AbstractSearch $search * @param SearchState $search
* @param string $query * @param string $query
*/ */
protected function applyFulltext(AbstractSearch $search, $query) protected function applyFulltext(SearchState $search, $query)
{ {
if (! $this->fulltextGambit) { if (! $this->fulltextGambit) {
return; return;

View File

@ -13,22 +13,22 @@ use Flarum\User\User;
/** /**
* Represents the criteria that will determine the entire result set of a * 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. * which part of the entire result set will be returned.
*/ */
class SearchCriteria class SearchCriteria
{ {
/** /**
* The user performing the search. * The user performing the query.
* *
* @var User * @var User
*/ */
public $actor; public $actor;
/** /**
* The search query. * Query params.
* *
* @var string * @var array
*/ */
public $query; public $query;
@ -42,8 +42,8 @@ class SearchCriteria
public $sort; public $sort;
/** /**
* @param User $actor The user performing the search. * @param User $actor The user performing the query.
* @param string $query The search query. * @param array $query The query params.
* @param array $sort An array of sort-order pairs, where the column is the * @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 * key, and the order is the value. The order may be 'asc', 'desc', or
* an array of IDs to order by. * an array of IDs to order by.

View File

@ -9,19 +9,19 @@
namespace Flarum\Search; 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\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\FulltextGambit as DiscussionFulltextGambit;
use Flarum\Discussion\Search\Gambit\HiddenGambit;
use Flarum\Discussion\Search\Gambit\UnreadGambit;
use Flarum\Event\ConfigureDiscussionGambits; use Flarum\Event\ConfigureDiscussionGambits;
use Flarum\Event\ConfigureUserGambits; use Flarum\Event\ConfigureUserGambits;
use Flarum\Foundation\AbstractServiceProvider; use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\ContainerUtil; 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\FulltextGambit as UserFulltextGambit;
use Flarum\User\Search\Gambit\GroupGambit;
use Flarum\User\Search\UserSearcher; use Flarum\User\Search\UserSearcher;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@ -42,14 +42,14 @@ class SearchServiceProvider extends AbstractServiceProvider
$this->app->singleton('flarum.simple_search.gambits', function () { $this->app->singleton('flarum.simple_search.gambits', function () {
return [ return [
DiscussionSearcher::class => [ DiscussionSearcher::class => [
AuthorGambit::class, AuthorFilterGambit::class,
CreatedGambit::class, CreatedFilterGambit::class,
HiddenGambit::class, HiddenFilterGambit::class,
UnreadGambit::class UnreadFilterGambit::class
], ],
UserSearcher::class => [ UserSearcher::class => [
EmailGambit::class, EmailFilterGambit::class,
GroupGambit::class GroupFilterGambit::class
] ]
]; ];
}); });

View File

@ -7,10 +7,8 @@
* LICENSE file that was distributed with this source code. * LICENSE file that was distributed with this source code.
*/ */
namespace Flarum\User\Search; namespace Flarum\Search;
use Flarum\Search\AbstractSearch; class SearchState extends AbstractSearch
class UserSearch extends AbstractSearch
{ {
} }

View File

@ -10,7 +10,7 @@
namespace Flarum\User\Event; namespace Flarum\User\Event;
use Flarum\Search\SearchCriteria; use Flarum\Search\SearchCriteria;
use Flarum\User\Search\UserSearch; use Flarum\Search\SearchState;
/** /**
* @deprecated beta 16, remove beta 17 * @deprecated beta 16, remove beta 17
@ -18,7 +18,7 @@ use Flarum\User\Search\UserSearch;
class Searching class Searching
{ {
/** /**
* @var \Flarum\User\Search\UserSearch * @var \Flarum\User\Search\SearchState
*/ */
public $search; public $search;
@ -28,10 +28,10 @@ class Searching
public $criteria; public $criteria;
/** /**
* @param UserSearch $search * @param SearchState $search
* @param SearchCriteria $criteria * @param SearchCriteria $criteria
*/ */
public function __construct(UserSearch $search, SearchCriteria $criteria) public function __construct(SearchState $search, SearchCriteria $criteria)
{ {
$this->search = $search; $this->search = $search;
$this->criteria = $criteria; $this->criteria = $criteria;

View File

@ -0,0 +1,68 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User\Filter;
use Flarum\Filter\FilterInterface;
use Flarum\Filter\FilterState;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\SearchState;
use Illuminate\Database\Query\Builder;
class EmailFilterGambit extends AbstractRegexGambit implements FilterInterface
{
/**
* {@inheritdoc}
*/
public function apply(SearchState $search, $bit)
{
if (! $search->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);
}
}

View File

@ -0,0 +1,68 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User\Filter;
use Flarum\Filter\FilterInterface;
use Flarum\Filter\FilterState;
use Flarum\Group\Group;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\SearchState;
use Flarum\User\User;
use Illuminate\Database\Query\Builder;
class GroupFilterGambit extends AbstractRegexGambit implements FilterInterface
{
/**
* {@inheritdoc}
*/
public function getGambitPattern()
{
return 'group:(.+)';
}
/**
* {@inheritdoc}
*/
protected function conditions(SearchState $search, array $matches, $negate)
{
$this->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);
}
}

View File

@ -0,0 +1,40 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User\Filter;
use Flarum\Filter\AbstractFilterer;
use Flarum\User\User;
use Flarum\User\UserRepository;
use Illuminate\Database\Eloquent\Builder;
class UserFilterer extends AbstractFilterer
{
/**
* @var UserRepository
*/
protected $users;
/**
* @param UserRepository $users
* @param array $filters
* @param array $filterMutators
*/
public function __construct(UserRepository $users, array $filters, array $filterMutators)
{
parent::__construct($filters, $filterMutators);
$this->users = $users;
}
protected function getQuery(User $actor): Builder
{
return $this->users->query()->whereVisibleTo($actor);
}
}

View File

@ -1,63 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User\Search\Gambit;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\AbstractSearch;
use Flarum\User\Search\UserSearch;
use Flarum\User\UserRepository;
use LogicException;
class EmailGambit extends AbstractRegexGambit
{
/**
* {@inheritdoc}
*/
protected $pattern = 'email:(.+)';
/**
* @var \Flarum\User\UserRepository
*/
protected $users;
/**
* @param \Flarum\User\UserRepository $users
*/
public function __construct(UserRepository $users)
{
$this->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);
}
}

View File

@ -9,8 +9,8 @@
namespace Flarum\User\Search\Gambit; namespace Flarum\User\Search\Gambit;
use Flarum\Search\AbstractSearch;
use Flarum\Search\GambitInterface; use Flarum\Search\GambitInterface;
use Flarum\Search\SearchState;
use Flarum\User\UserRepository; use Flarum\User\UserRepository;
class FulltextGambit implements GambitInterface class FulltextGambit implements GambitInterface
@ -43,7 +43,7 @@ class FulltextGambit implements GambitInterface
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function apply(AbstractSearch $search, $searchValue) public function apply(SearchState $search, $searchValue)
{ {
$search->getQuery() $search->getQuery()
->whereIn( ->whereIn(

View File

@ -1,77 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User\Search\Gambit;
use Flarum\Group\Group;
use Flarum\Group\GroupRepository;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\AbstractSearch;
use Flarum\User\Search\UserSearch;
use LogicException;
class GroupGambit extends AbstractRegexGambit
{
/**
* {@inheritdoc}
*/
protected $pattern = 'group:(.+)';
/**
* @var GroupRepository
*/
protected $groups;
/**
* @param \Flarum\Group\GroupRepository $groups
*/
public function __construct(GroupRepository $groups)
{
$this->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], '"'));
}
}

View File

@ -9,10 +9,10 @@
namespace Flarum\User\Search; namespace Flarum\User\Search;
use Flarum\Search\AbstractSearch;
use Flarum\Search\AbstractSearcher; use Flarum\Search\AbstractSearcher;
use Flarum\Search\GambitManager; use Flarum\Search\GambitManager;
use Flarum\Search\SearchCriteria; use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchState;
use Flarum\User\Event\Searching; use Flarum\User\Event\Searching;
use Flarum\User\User; use Flarum\User\User;
use Flarum\User\UserRepository; use Flarum\User\UserRepository;
@ -54,15 +54,10 @@ class UserSearcher extends AbstractSearcher
return $this->users->query()->whereVisibleTo($actor); 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. * @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); parent::mutateSearch($search, $criteria);

View File

@ -12,6 +12,8 @@ namespace Flarum\Tests\integration\api\discussions;
use Carbon\Carbon; use Carbon\Carbon;
use Flarum\Tests\integration\RetrievesAuthorizedUsers; use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase; use Flarum\Tests\integration\TestCase;
use Flarum\User\User;
use Illuminate\Support\Arr;
class ListTest extends TestCase class ListTest extends TestCase
{ {
@ -26,10 +28,16 @@ class ListTest extends TestCase
$this->prepareDatabase([ $this->prepareDatabase([
'discussions' => [ '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' => [ 'posts' => [
['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>'], ['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>'],
['id' => 2, 'discussion_id' => 2, 'created_at' => Carbon::createFromDate(1985, 5, 21)->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>not in text</p></t>'],
['id' => 3, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1995, 5, 21)->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>lightsail in text</p></t>'],
['id' => 4, 'discussion_id' => 4, 'created_at' => Carbon::createFromDate(2005, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>lightsail in text</p></t>'],
], ],
'users' => [ 'users' => [
$this->normalUser(), $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 * @test
*/ */
@ -49,22 +67,398 @@ class ListTest extends TestCase
$this->assertEquals(200, $response->getStatusCode()); $this->assertEquals(200, $response->getStatusCode());
$data = json_decode($response->getBody()->getContents(), true); $data = json_decode($response->getBody()->getContents(), true);
$this->assertEquals(1, count($data['data'])); $this->assertEquals(3, count($data['data']));
} }
/** /**
* @test * @test
*/ */
public function can_search_for_author() public function author_filter_works()
{ {
$response = $this->send( $response = $this->send(
$this->request('GET', '/api/discussions') $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([ ->withQueryParams([
'filter' => ['q' => 'author:normal foo'], 'filter' => ['unread' => ''],
'include' => 'mostRelevantPost', '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);
} }
} }

View File

@ -11,11 +11,26 @@ namespace Flarum\Tests\integration\api\users;
use Flarum\Tests\integration\RetrievesAuthorizedUsers; use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase; use Flarum\Tests\integration\TestCase;
use Illuminate\Support\Arr;
class ListTest extends TestCase class ListTest extends TestCase
{ {
use RetrievesAuthorizedUsers; use RetrievesAuthorizedUsers;
/**
* @inheritDoc
*/
protected function setUp(): void
{
parent::setUp();
$this->prepareDatabase([
'users' => [
$this->normalUser(),
],
]);
}
/** /**
* @test * @test
*/ */
@ -59,4 +74,200 @@ class ListTest extends TestCase
$this->assertEquals(200, $response->getStatusCode()); $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'));
}
} }

View File

@ -0,0 +1,134 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\extenders;
use Carbon\Carbon;
use Flarum\Discussion\Filter\DiscussionFilterer;
use Flarum\Extend;
use Flarum\Filter\FilterInterface;
use Flarum\Filter\FilterState;
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
class FilterTest extends TestCase
{
use RetrievesAuthorizedUsers;
public function prepDb()
{
$this->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' => '<t><p>foo bar</p></t>'],
['id' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>foo bar not the same</p></t>'],
],
'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');
}
}

View File

@ -13,9 +13,9 @@ use Carbon\Carbon;
use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Discussion\Search\DiscussionSearcher;
use Flarum\Extend; use Flarum\Extend;
use Flarum\Search\AbstractRegexGambit; use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\AbstractSearch;
use Flarum\Search\GambitInterface; use Flarum\Search\GambitInterface;
use Flarum\Search\SearchCriteria; use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchState;
use Flarum\Tests\integration\RetrievesAuthorizedUsers; use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase; use Flarum\Tests\integration\TestCase;
use Flarum\User\User; use Flarum\User\User;
@ -64,7 +64,7 @@ class SimpleFlarumSearchTest extends TestCase
$actor = User::find(1); $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(); return $this->app()->getContainer()->make(DiscussionSearcher::class)->search($criteria, $limit)->getResults();
} }
@ -142,7 +142,7 @@ class NoResultFullTextGambit implements GambitInterface
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function apply(AbstractSearch $search, $searchValue) public function apply(SearchState $search, $searchValue)
{ {
$search->getQuery() $search->getQuery()
->whereRaw('0=1'); ->whereRaw('0=1');
@ -151,12 +151,18 @@ class NoResultFullTextGambit implements GambitInterface
class NoResultFilterGambit extends AbstractRegexGambit class NoResultFilterGambit extends AbstractRegexGambit
{ {
protected $pattern = 'noResult:(.+)'; /**
* {@inheritdoc}
*/
public function getGambitPattern()
{
return 'noResult:(.+)';
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function conditions(AbstractSearch $search, array $matches, $negate) public function conditions(SearchState $search, array $matches, $negate)
{ {
$noResults = trim($matches[1], ' '); $noResults = trim($matches[1], ' ');
if ($noResults == '1') { if ($noResults == '1') {