Simple Flarum Search Extender and tests (#2483)

This commit is contained in:
Alexander Skvortsov 2021-02-10 09:59:23 -05:00 committed by GitHub
parent 458ae2bfbe
commit 4661de4ddc
12 changed files with 525 additions and 148 deletions

View File

@ -12,6 +12,9 @@ namespace Flarum\Discussion\Event;
use Flarum\Discussion\Search\DiscussionSearch;
use Flarum\Search\SearchCriteria;
/**
* @deprecated beta 16, remove beta 17
*/
class Searching
{
/**

View File

@ -11,21 +11,16 @@ namespace Flarum\Discussion\Search;
use Flarum\Discussion\DiscussionRepository;
use Flarum\Discussion\Event\Searching;
use Flarum\Search\ApplySearchParametersTrait;
use Flarum\Search\AbstractSearch;
use Flarum\Search\AbstractSearcher;
use Flarum\Search\GambitManager;
use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchResults;
use Flarum\User\User;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Eloquent\Builder;
class DiscussionSearcher
class DiscussionSearcher extends AbstractSearcher
{
use ApplySearchParametersTrait;
/**
* @var GambitManager
*/
protected $gambits;
/**
* @var DiscussionRepository
*/
@ -37,53 +32,36 @@ class DiscussionSearcher
protected $events;
/**
* @param GambitManager $gambits
* @param DiscussionRepository $discussions
* @param Dispatcher $events
* @param GambitManager $gambits
* @param array $searchMutators
*/
public function __construct(GambitManager $gambits, DiscussionRepository $discussions, Dispatcher $events)
public function __construct(DiscussionRepository $discussions, Dispatcher $events, GambitManager $gambits, array $searchMutators)
{
$this->gambits = $gambits;
parent::__construct($gambits, $searchMutators);
$this->discussions = $discussions;
$this->events = $events;
}
/**
* @param SearchCriteria $criteria
* @param int|null $limit
* @param int $offset
*
* @return SearchResults
*/
public function search(SearchCriteria $criteria, $limit = null, $offset = 0)
protected function getQuery(User $actor): Builder
{
$actor = $criteria->actor;
return $this->discussions->query()->select('discussions.*')->whereVisibleTo($actor);
}
$query = $this->discussions->query()->select('discussions.*')->whereVisibleTo($actor);
protected function getSearch(Builder $query, User $actor): AbstractSearch
{
return new DiscussionSearch($query->getQuery(), $actor);
}
// Construct an object which represents this search for discussions.
// Apply gambits to it, sort, and paging criteria. Also give extensions
// an opportunity to modify it.
$search = new DiscussionSearch($query->getQuery(), $actor);
$this->gambits->apply($search, $criteria->query);
$this->applySort($search, $criteria->sort);
$this->applyOffset($search, $offset);
$this->applyLimit($search, $limit + 1);
/**
* @deprecated along with the Searching event, remove in Beta 17.
*/
protected function mutateSearch(AbstractSearch $search, SearchCriteria $criteria)
{
parent::mutateSearch($search, $criteria);
$this->events->dispatch(new Searching($search, $criteria));
// Execute the search 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.
$discussions = $query->get();
$areMoreResults = $limit > 0 && $discussions->count() > $limit;
if ($areMoreResults) {
$discussions->pop();
}
return new SearchResults($discussions, $areMoreResults);
}
}

View File

@ -11,6 +11,9 @@ namespace Flarum\Event;
use Flarum\Search\GambitManager;
/**
* @deprecated beta 16, removed in beta 17
*/
abstract class AbstractConfigureGambits
{
/**

View File

@ -9,6 +9,9 @@
namespace Flarum\Event;
/**
* @deprecated beta 16, removed in beta 17
*/
class ConfigureDiscussionGambits extends AbstractConfigureGambits
{
}

View File

@ -9,6 +9,9 @@
namespace Flarum\Event;
/**
* @deprecated beta 16, removed in beta 17
*/
class ConfigureUserGambits extends AbstractConfigureGambits
{
}

View File

@ -0,0 +1,99 @@
<?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 SimpleFlarumSearch implements ExtenderInterface
{
private $fullTextGambit;
private $gambits = [];
private $searcher;
private $searchMutators = [];
/**
* @param string $searcherClass: The ::class attribute of the Searcher you are modifying.
* This searcher must extend \Flarum\Search\AbstractSearcher.
*/
public function __construct($searcherClass)
{
$this->searcher = $searcherClass;
}
/**
* Add a gambit to this searcher. Gambits are used to filter search queries.
*
* @param string $gambitClass: The ::class attribute of the gambit you are adding.
* This gambit must extend \Flarum\Search\AbstractRegexGambit
*/
public function addGambit($gambitClass)
{
$this->gambits[] = $gambitClass;
return $this;
}
/**
* Set the full text gambit for this searcher. The full text gambit actually executes the search.
*
* @param string $gambitClass: The ::class attribute of the full test gambit you are adding.
* This gambit must implement \Flarum\Search\GambitInterface
*/
public function setFullTextGambit($gambitClass)
{
$this->fullTextGambit = $gambitClass;
return $this;
}
/**
* Add a callback through which to run all search queries after gambits have been applied.
*
* @param callable|string $callback
*
* The callback can be a closure or an invokable class, and should accept:
* - Flarum\Search\AbstractSearch $search
* - Flarum\Search\SearchCriteria $criteria
*/
public function addSearchMutator($callback)
{
$this->searchMutators[] = $callback;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
if (! is_null($this->fullTextGambit)) {
$container->resolving('flarum.simple_search.fulltext_gambits', function ($oldFulltextGambits) {
$oldFulltextGambits[$this->searcher] = $this->fullTextGambit;
return $oldFulltextGambits;
});
}
$container->extend('flarum.simple_search.gambits', function ($oldGambits) {
foreach ($this->gambits as $gambit) {
$oldGambits[$this->searcher][] = $gambit;
}
return $oldGambits;
});
$container->extend('flarum.simple_search.search_mutators', function ($oldMutators) {
foreach ($this->searchMutators as $mutator) {
$oldMutators[$this->searcher][] = $mutator;
}
return $oldMutators;
});
}
}

View File

@ -0,0 +1,81 @@
<?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\Search;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
abstract class AbstractSearcher
{
use ApplySearchParametersTrait;
/**
* @var GambitManager
*/
protected $gambits;
/**
* @var array
*/
protected $searchMutators;
public function __construct(GambitManager $gambits, array $searchMutators)
{
$this->gambits = $gambits;
$this->searchMutators = $searchMutators;
}
abstract protected function getQuery(User $actor): Builder;
abstract protected function getSearch(Builder $query, User $actor): AbstractSearch;
protected function mutateSearch(AbstractSearch $search, SearchCriteria $criteria)
{
foreach ($this->searchMutators as $mutator) {
$mutator($search, $criteria);
}
}
/**
* @param SearchCriteria $criteria
* @param int|null $limit
* @param int $offset
*
* @return SearchResults
*/
public function search(SearchCriteria $criteria, $limit = null, $offset = 0, array $load = [])
{
$actor = $criteria->actor;
$query = $this->getQuery($actor);
$search = $this->getSearch($query, $actor);
$this->gambits->apply($search, $criteria->query);
$this->applySort($search, $criteria->sort);
$this->applyOffset($search, $offset);
$this->applyLimit($search, $limit + 1);
$this->mutateSearch($search, $criteria);
// Execute the search 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();
}
$results->load($load);
return new SearchResults($results, $areMoreResults);
}
}

View File

@ -9,7 +9,6 @@
namespace Flarum\Search;
use Illuminate\Contracts\Container\Container;
use LogicException;
/**
@ -23,33 +22,36 @@ class GambitManager
protected $gambits = [];
/**
* @var string
* @var GambitInterface
*/
protected $fulltextGambit;
/**
* @var Container
*/
protected $container;
/**
* @param Container $container
*/
public function __construct(Container $container)
{
$this->container = $container;
}
/**
* Add a gambit.
*
* @param string $gambit
* @param GambitInterface $gambit
*/
public function add($gambit)
{
$this->gambits[] = $gambit;
}
/**
* @deprecated Do not use. Added temporarily to provide support for ConfigureUserGambits and ConfigureDiscussionGambits until they are removed in beta 17.
*/
public function getFullTextGambit()
{
return $this->fulltextGambit;
}
/**
* @deprecated Do not use. Added temporarily to provide support for ConfigureUserGambits and ConfigureDiscussionGambits until they are removed in beta 17.
*/
public function getGambits()
{
return $this->gambits;
}
/**
* Apply gambits to a search, given a search query.
*
@ -68,7 +70,7 @@ class GambitManager
/**
* Set the gambit to handle fulltext searching.
*
* @param string $gambit
* @param GambitInterface $gambit
*/
public function setFulltextGambit($gambit)
{
@ -99,10 +101,8 @@ class GambitManager
return '';
}
$gambits = array_map([$this->container, 'make'], $this->gambits);
foreach ($bits as $k => $bit) {
foreach ($gambits as $gambit) {
foreach ($this->gambits as $gambit) {
if (! $gambit instanceof GambitInterface) {
throw new LogicException(
'Gambit '.get_class($gambit).' does not implement '.GambitInterface::class
@ -130,9 +130,7 @@ class GambitManager
return;
}
$gambit = $this->container->make($this->fulltextGambit);
$search->addActiveGambit($gambit);
$gambit->apply($search, $query);
$search->addActiveGambit($this->fulltextGambit);
$this->fulltextGambit->apply($search, $query);
}
}

View File

@ -18,63 +18,108 @@ 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\Search\Gambit\FulltextGambit as UserFulltextGambit;
use Flarum\User\Search\Gambit\GroupGambit;
use Flarum\User\Search\UserSearcher;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\Arr;
class SearchServiceProvider extends AbstractServiceProvider
{
/**
* Register the service provider.
*
* @return void
* @inheritDoc
*/
public function register()
{
$this->registerDiscussionGambits();
$this->app->singleton('flarum.simple_search.fulltext_gambits', function () {
return [
DiscussionSearcher::class => DiscussionFulltextGambit::class,
UserSearcher::class => UserFulltextGambit::class
];
});
$this->registerUserGambits();
}
$this->app->singleton('flarum.simple_search.gambits', function () {
return [
DiscussionSearcher::class => [
AuthorGambit::class,
CreatedGambit::class,
HiddenGambit::class,
UnreadGambit::class
],
UserSearcher::class => [
EmailGambit::class,
GroupGambit::class
]
];
});
public function registerUserGambits()
{
$this->app->when(UserSearcher::class)
->needs(GambitManager::class)
->give(function (Container $app) {
$gambits = new GambitManager($app);
$gambits->setFulltextGambit(UserFulltextGambit::class);
$gambits->add(EmailGambit::class);
$gambits->add(GroupGambit::class);
$app->make('events')->dispatch(
new ConfigureUserGambits($gambits)
);
return $gambits;
$this->app->singleton('flarum.simple_search.search_mutators', function () {
return [];
});
}
public function registerDiscussionGambits()
/**
* {@inheritdoc}
*/
public function boot()
{
$this->app->when(DiscussionSearcher::class)
// The rest of these we can resolve in the when->needs->give callback,
// but we need to resolve at least one regardless so we know which
// searchers we need to register gambits for.
$fullTextGambits = $this->app->make('flarum.simple_search.fulltext_gambits');
foreach ($fullTextGambits as $searcher => $fullTextGambitClass) {
$this->app
->when($searcher)
->needs(GambitManager::class)
->give(function (Container $app) {
$gambits = new GambitManager($app);
->give(function () use ($searcher, $fullTextGambitClass) {
$gambitManager = new GambitManager();
$gambitManager->setFulltextGambit($this->app->make($fullTextGambitClass));
foreach (Arr::get($this->app->make('flarum.simple_search.gambits'), $searcher, []) as $gambit) {
$gambitManager->add($this->app->make($gambit));
}
$gambits->setFulltextGambit(DiscussionFulltextGambit::class);
$gambits->add(AuthorGambit::class);
$gambits->add(CreatedGambit::class);
$gambits->add(HiddenGambit::class);
$gambits->add(UnreadGambit::class);
// Temporary BC Layer
// @deprecated beta 16, remove beta 17.
$app->make('events')->dispatch(
new ConfigureDiscussionGambits($gambits)
$oldEvents = [
DiscussionSearcher::class => ConfigureDiscussionGambits::class,
UserSearcher::class => ConfigureUserGambits::class
];
foreach ($oldEvents as $oldSearcher => $event) {
if ($searcher === $oldSearcher) {
$tempGambits = new GambitManager;
$this->app->make('events')->dispatch(
new $event($tempGambits)
);
return $gambits;
if (! is_null($fullTextGambit = $tempGambits->getFullTextGambit())) {
$gambitManager->setFullTextGambit($this->app->make($fullTextGambit));
}
foreach ($tempGambits->getGambits() as $gambit) {
$gambitManager->add($this->app->make($gambit));
}
}
}
// End BC Layer
return $gambitManager;
});
$this->app
->when($searcher)
->needs('$searchMutators')
->give(function () use ($searcher) {
$searchMutators = Arr::get($this->app->make('flarum.simple_search.search_mutators'), $searcher, []);
return array_map(function ($mutator) {
return ContainerUtil::wrapCallback($mutator, $this->app);
}, $searchMutators);
});
}
}
}

View File

@ -12,6 +12,9 @@ namespace Flarum\User\Event;
use Flarum\Search\SearchCriteria;
use Flarum\User\Search\UserSearch;
/**
* @deprecated beta 16, remove beta 17
*/
class Searching
{
/**

View File

@ -9,25 +9,26 @@
namespace Flarum\User\Search;
use Flarum\Search\ApplySearchParametersTrait;
use Flarum\Search\AbstractSearch;
use Flarum\Search\AbstractSearcher;
use Flarum\Search\GambitManager;
use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchResults;
use Flarum\User\Event\Searching;
use Flarum\User\User;
use Flarum\User\UserRepository;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Eloquent\Builder;
/**
* Takes a UserSearchCriteria object, performs a search using gambits,
* and spits out a UserSearchResults object.
*/
class UserSearcher
class UserSearcher extends AbstractSearcher
{
use ApplySearchParametersTrait;
/**
* @var GambitManager
* @var Dispatcher
*/
protected $gambits;
protected $events;
/**
* @var UserRepository
@ -35,51 +36,36 @@ class UserSearcher
protected $users;
/**
* @param UserRepository $users
* @param Dispatcher $events
* @param GambitManager $gambits
* @param \Flarum\User\UserRepository $users
* @param array $searchMutators
*/
public function __construct(GambitManager $gambits, UserRepository $users)
public function __construct(UserRepository $users, Dispatcher $events, GambitManager $gambits, array $searchMutators)
{
$this->gambits = $gambits;
parent::__construct($gambits, $searchMutators);
$this->events = $events;
$this->users = $users;
}
/**
* @param SearchCriteria $criteria
* @param int|null $limit
* @param int $offset
* @param array $load An array of relationships to load on the results.
* @return SearchResults
*/
public function search(SearchCriteria $criteria, $limit = null, $offset = 0, array $load = [])
protected function getQuery(User $actor): Builder
{
$actor = $criteria->actor;
$query = $this->users->query()->whereVisibleTo($actor);
// Construct an object which represents this search for users.
// Apply gambits to it, sort, and paging criteria. Also give extensions
// an opportunity to modify it.
$search = new UserSearch($query->getQuery(), $actor);
$this->gambits->apply($search, $criteria->query);
$this->applySort($search, $criteria->sort);
$this->applyOffset($search, $offset);
$this->applyLimit($search, $limit + 1);
event(new Searching($search, $criteria));
// Execute the search 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.
$users = $query->get();
if ($areMoreResults = ($limit > 0 && $users->count() > $limit)) {
$users->pop();
return $this->users->query()->whereVisibleTo($actor);
}
$users->load($load);
protected function getSearch(Builder $query, User $actor): AbstractSearch
{
return new UserSearch($query->getQuery(), $actor);
}
return new SearchResults($users, $areMoreResults);
/**
* @deprecated along with the Searching event, remove in Beta 17.
*/
protected function mutateSearch(AbstractSearch $search, SearchCriteria $criteria)
{
parent::mutateSearch($search, $criteria);
$this->events->dispatch(new Searching($search, $criteria));
}
}

View File

@ -0,0 +1,175 @@
<?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\Search\DiscussionSearcher;
use Flarum\Extend;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\AbstractSearch;
use Flarum\Search\GambitInterface;
use Flarum\Search\SearchCriteria;
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
use Flarum\User\User;
class SimpleFlarumSearchTest extends TestCase
{
use RetrievesAuthorizedUsers;
public function prepDb()
{
$this->database()->rollBack();
// We need to insert these outside of a transaction, because FULLTEXT indexing,
// which is needed for search, doesn't happen in transactions.
// We clean it up explcitly at the end.
$this->database()->table('discussions')->insert([
['id' => 1, 'title' => 'DISCUSSION 1', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 1, 'comment_count' => 1],
['id' => 2, 'title' => 'DISCUSSION 2', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 1, 'comment_count' => 1],
]);
$this->database()->table('posts')->insert([
['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>not in text</p></t>'],
['id' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>lightsail in text</p></t>'],
]);
// We need to call these again, since we rolled back the transaction started by `::app()`.
$this->database()->beginTransaction();
$this->populateDatabase();
}
/**
* @inheritDoc
*/
protected function tearDown(): void
{
parent::tearDown();
$this->database()->table('discussions')->whereIn('id', [1, 2])->delete();
$this->database()->table('posts')->whereIn('id', [1, 2])->delete();
}
public function searchDiscussions($query, $limit = null)
{
$this->app();
$actor = User::find(1);
$criteria = new SearchCriteria($actor, $query);
return $this->app()->getContainer()->make(DiscussionSearcher::class)->search($criteria, $limit)->getResults();
}
/**
* @test
*/
public function works_as_expected_with_no_modifications()
{
$this->prepDb();
$searchForAll = json_encode($this->searchDiscussions('in text', 5));
$this->assertContains('DISCUSSION 1', $searchForAll);
$this->assertContains('DISCUSSION 2', $searchForAll);
$searchForSecond = json_encode($this->searchDiscussions('lightsail', 5));
$this->assertNotContains('DISCUSSION 1', $searchForSecond);
$this->assertContains('DISCUSSION 2', $searchForSecond);
}
/**
* @test
*/
public function custom_full_text_gambit_has_effect_if_added()
{
$this->extend((new Extend\SimpleFlarumSearch(DiscussionSearcher::class))->setFullTextGambit(NoResultFullTextGambit::class));
$this->assertEquals('[]', json_encode($this->searchDiscussions('in text', 5)));
}
/**
* @test
*/
public function custom_filter_gambit_has_effect_if_added()
{
$this->extend((new Extend\SimpleFlarumSearch(DiscussionSearcher::class))->addGambit(NoResultFilterGambit::class));
$this->prepDb();
$withResultSearch = json_encode($this->searchDiscussions('noResult:0', 5));
$this->assertContains('DISCUSSION 1', $withResultSearch);
$this->assertContains('DISCUSSION 2', $withResultSearch);
$this->assertEquals('[]', json_encode($this->searchDiscussions('noResult:1', 5)));
}
/**
* @test
*/
public function search_mutator_has_effect_if_added()
{
$this->extend((new Extend\SimpleFlarumSearch(DiscussionSearcher::class))->addSearchMutator(function ($search, $criteria) {
$search->getquery()->whereRaw('1=0');
}));
$this->prepDb();
$this->assertEquals('[]', json_encode($this->searchDiscussions('in text', 5)));
}
/**
* @test
*/
public function search_mutator_has_effect_if_added_with_invokable_class()
{
$this->extend((new Extend\SimpleFlarumSearch(DiscussionSearcher::class))->addSearchMutator(CustomSearchMutator::class));
$this->prepDb();
$this->assertEquals('[]', json_encode($this->searchDiscussions('in text', 5)));
}
}
class NoResultFullTextGambit implements GambitInterface
{
/**
* {@inheritdoc}
*/
public function apply(AbstractSearch $search, $searchValue)
{
$search->getQuery()
->whereRaw('0=1');
}
}
class NoResultFilterGambit extends AbstractRegexGambit
{
protected $pattern = 'noResult:(.+)';
/**
* {@inheritdoc}
*/
public function conditions(AbstractSearch $search, array $matches, $negate)
{
$noResults = trim($matches[1], ' ');
if ($noResults == '1') {
$search->getQuery()
->whereRaw('0=1');
}
}
}
class CustomSearchMutator
{
public function __invoke($search, $criteria)
{
$search->getQuery()->whereRaw('1=0');
}
}