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 1c578a83e4
commit 023871ef86
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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\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) {

View File

@ -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);
}
}

View File

@ -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;

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.
*/
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 {

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\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);

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;
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

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);
}
});
}
}

74
src/Extend/Filter.php Normal file
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
*
* 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)

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\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);

View File

@ -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);
}

View File

@ -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.
*

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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);
}

View File

@ -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;

View File

@ -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.

View File

@ -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
]
];
});

View File

@ -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
{
}

View File

@ -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;

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;
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(

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;
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);

View File

@ -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' => '<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' => [
$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);
}
}

View File

@ -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'));
}
}

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\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') {