diff --git a/extensions/likes/extend.php b/extensions/likes/extend.php index 434df72cdc..95ee5d1efe 100644 --- a/extensions/likes/extend.php +++ b/extensions/likes/extend.php @@ -21,6 +21,7 @@ use Flarum\Likes\Query\LikedFilter; use Flarum\Post\Filter\PostSearcher; use Flarum\Post\Post; +use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\User\Search\UserSearcher; use Flarum\User\User; @@ -76,11 +77,9 @@ ->listen(PostWasUnliked::class, Listener\SendNotificationWhenPostIsUnliked::class) ->subscribe(Listener\SaveLikesToDatabase::class), - (new Extend\SimpleFlarumSearch(PostSearcher::class)) - ->addFilter(LikedByFilter::class), - - (new Extend\SimpleFlarumSearch(UserSearcher::class)) - ->addFilter(LikedFilter::class), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addFilter(PostSearcher::class, LikedByFilter::class) + ->addFilter(UserSearcher::class, LikedFilter::class), (new Extend\Settings()) ->default('flarum-likes.like_own_post', true), diff --git a/extensions/likes/src/Query/LikedByFilter.php b/extensions/likes/src/Query/LikedByFilter.php index e31acf507a..6fdb89c9a2 100644 --- a/extensions/likes/src/Query/LikedByFilter.php +++ b/extensions/likes/src/Query/LikedByFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Likes\Query; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class LikedByFilter implements FilterInterface { use ValidateFilterTrait; diff --git a/extensions/likes/src/Query/LikedFilter.php b/extensions/likes/src/Query/LikedFilter.php index 17524eb668..b291d97eaf 100644 --- a/extensions/likes/src/Query/LikedFilter.php +++ b/extensions/likes/src/Query/LikedFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Likes\Query; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class LikedFilter implements FilterInterface { use ValidateFilterTrait; diff --git a/extensions/lock/extend.php b/extensions/lock/extend.php index 6d0ab38a0b..26000af97a 100644 --- a/extensions/lock/extend.php +++ b/extensions/lock/extend.php @@ -20,6 +20,7 @@ use Flarum\Lock\Listener; use Flarum\Lock\Notification\DiscussionLockedBlueprint; use Flarum\Lock\Post\DiscussionLockedPost; +use Flarum\Search\Database\DatabaseSearchDriver; return [ (new Extend\Frontend('forum')) @@ -56,6 +57,6 @@ (new Extend\Policy()) ->modelPolicy(Discussion::class, Access\DiscussionPolicy::class), - (new Extend\SimpleFlarumSearch(DiscussionSearcher::class)) - ->addFilter(LockedFilter::class), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addFilter(DiscussionSearcher::class, LockedFilter::class), ]; diff --git a/extensions/lock/src/Filter/LockedFilter.php b/extensions/lock/src/Filter/LockedFilter.php index 02cdf411ef..6fb2349f59 100644 --- a/extensions/lock/src/Filter/LockedFilter.php +++ b/extensions/lock/src/Filter/LockedFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Lock\Filter; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Illuminate\Database\Query\Builder; +/** + * @implements FilterInterface + */ class LockedFilter implements FilterInterface { public function getFilterKey(): string diff --git a/extensions/mentions/extend.php b/extensions/mentions/extend.php index 20c7798eff..6f3c48f6e4 100644 --- a/extensions/mentions/extend.php +++ b/extensions/mentions/extend.php @@ -26,6 +26,7 @@ use Flarum\Post\Event\Revised; use Flarum\Post\Filter\PostSearcher; use Flarum\Post\Post; +use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\Tags\Api\Serializer\TagSerializer; use Flarum\Tags\Tag; use Flarum\User\User; @@ -115,9 +116,9 @@ ->listen(Hidden::class, Listener\UpdateMentionsMetadataWhenInvisible::class) ->listen(Deleted::class, Listener\UpdateMentionsMetadataWhenInvisible::class), - (new Extend\SimpleFlarumSearch(PostSearcher::class)) - ->addFilter(Filter\MentionedFilter::class) - ->addFilter(Filter\MentionedPostFilter::class), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addFilter(PostSearcher::class, Filter\MentionedFilter::class) + ->addFilter(PostSearcher::class, Filter\MentionedPostFilter::class), (new Extend\ApiSerializer(CurrentUserSerializer::class)) ->attribute('canMentionGroups', function (CurrentUserSerializer $serializer, User $user): bool { diff --git a/extensions/mentions/src/Filter/MentionedFilter.php b/extensions/mentions/src/Filter/MentionedFilter.php index af0d96ba33..2c555de981 100644 --- a/extensions/mentions/src/Filter/MentionedFilter.php +++ b/extensions/mentions/src/Filter/MentionedFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Mentions\Filter; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class MentionedFilter implements FilterInterface { use ValidateFilterTrait; diff --git a/extensions/mentions/src/Filter/MentionedPostFilter.php b/extensions/mentions/src/Filter/MentionedPostFilter.php index 65bf8d2e3a..193ba33953 100644 --- a/extensions/mentions/src/Filter/MentionedPostFilter.php +++ b/extensions/mentions/src/Filter/MentionedPostFilter.php @@ -9,9 +9,13 @@ namespace Flarum\Mentions\Filter; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; +/** + * @implements FilterInterface + */ class MentionedPostFilter implements FilterInterface { public function getFilterKey(): string diff --git a/extensions/nicknames/extend.php b/extensions/nicknames/extend.php index 32e47b08a7..a0394c6197 100644 --- a/extensions/nicknames/extend.php +++ b/extensions/nicknames/extend.php @@ -12,6 +12,7 @@ use Flarum\Api\Serializer\UserSerializer; use Flarum\Extend; use Flarum\Nicknames\Access\UserPolicy; +use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\User\Event\Saving; use Flarum\User\Search\UserSearcher; use Flarum\User\User; @@ -51,8 +52,8 @@ (new Extend\Validator(UserValidator::class)) ->configure(AddNicknameValidation::class), - (new Extend\SimpleFlarumSearch(UserSearcher::class)) - ->setFullTextFilter(NicknameFullTextFilter::class), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->setFulltext(UserSearcher::class, NicknameFullTextFilter::class), (new Extend\Policy()) ->modelPolicy(User::class, UserPolicy::class), diff --git a/extensions/nicknames/src/NicknameFullTextFilter.php b/extensions/nicknames/src/NicknameFullTextFilter.php index 9cea9543e4..455f424be8 100644 --- a/extensions/nicknames/src/NicknameFullTextFilter.php +++ b/extensions/nicknames/src/NicknameFullTextFilter.php @@ -10,10 +10,14 @@ namespace Flarum\Nicknames; use Flarum\Search\AbstractFulltextFilter; +use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\SearchState; use Flarum\User\UserRepository; use Illuminate\Database\Eloquent\Builder; +/** + * @extends AbstractFulltextFilter + */ class NicknameFullTextFilter extends AbstractFulltextFilter { public function __construct( @@ -30,12 +34,12 @@ private function getUserSearchSubQuery(string $searchValue): Builder ->orWhere('nickname', 'like', "{$searchValue}%"); } - public function search(SearchState $state, string $query): void + public function search(SearchState $state, string $value): void { $state->getQuery() ->whereIn( 'id', - $this->getUserSearchSubQuery($query) + $this->getUserSearchSubQuery($value) ); } } diff --git a/extensions/sticky/extend.php b/extensions/sticky/extend.php index 61c55c9123..4ca43eec05 100644 --- a/extensions/sticky/extend.php +++ b/extensions/sticky/extend.php @@ -13,6 +13,7 @@ use Flarum\Discussion\Event\Saving; use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Extend; +use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\Sticky\Event\DiscussionWasStickied; use Flarum\Sticky\Event\DiscussionWasUnstickied; use Flarum\Sticky\Listener; @@ -53,7 +54,7 @@ ->listen(DiscussionWasStickied::class, [Listener\CreatePostWhenDiscussionIsStickied::class, 'whenDiscussionWasStickied']) ->listen(DiscussionWasUnstickied::class, [Listener\CreatePostWhenDiscussionIsStickied::class, 'whenDiscussionWasUnstickied']), - (new Extend\SimpleFlarumSearch(DiscussionSearcher::class)) - ->addFilter(StickyFilter::class) - ->addSearchMutator(PinStickiedDiscussionsToTop::class), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addFilter(DiscussionSearcher::class, StickyFilter::class) + ->addMutator(DiscussionSearcher::class, PinStickiedDiscussionsToTop::class), ]; diff --git a/extensions/sticky/src/PinStickiedDiscussionsToTop.php b/extensions/sticky/src/PinStickiedDiscussionsToTop.php index 77b44d8028..c8a044216c 100755 --- a/extensions/sticky/src/PinStickiedDiscussionsToTop.php +++ b/extensions/sticky/src/PinStickiedDiscussionsToTop.php @@ -9,13 +9,13 @@ namespace Flarum\Sticky; +use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\SearchCriteria; -use Flarum\Search\SearchState; use Flarum\Tags\Search\Filter\TagFilter; class PinStickiedDiscussionsToTop { - public function __invoke(SearchState $state, SearchCriteria $criteria): void + public function __invoke(DatabaseSearchState $state, SearchCriteria $criteria): void { if ($criteria->sortIsDefault && ! $state->isFulltextSearch()) { $query = $state->getQuery(); diff --git a/extensions/sticky/src/Query/StickyFilter.php b/extensions/sticky/src/Query/StickyFilter.php index 8a498e3e78..74ab036ebf 100644 --- a/extensions/sticky/src/Query/StickyFilter.php +++ b/extensions/sticky/src/Query/StickyFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Sticky\Query; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Illuminate\Database\Query\Builder; +/** + * @implements FilterInterface + */ class StickyFilter implements FilterInterface { public function getFilterKey(): string diff --git a/extensions/subscriptions/extend.php b/extensions/subscriptions/extend.php index 36942714ef..f114e11fea 100644 --- a/extensions/subscriptions/extend.php +++ b/extensions/subscriptions/extend.php @@ -19,6 +19,7 @@ use Flarum\Post\Event\Hidden; use Flarum\Post\Event\Posted; use Flarum\Post\Event\Restored; +use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\Subscriptions\Filter\SubscriptionFilter; use Flarum\Subscriptions\HideIgnoredFromAllDiscussionsPage; use Flarum\Subscriptions\Listener; @@ -69,9 +70,9 @@ ->listen(Deleted::class, Listener\DeleteNotificationWhenPostIsHiddenOrDeleted::class) ->listen(Posted::class, Listener\FollowAfterReply::class), - (new Extend\SimpleFlarumSearch(DiscussionSearcher::class)) - ->addFilter(SubscriptionFilter::class) - ->addSearchMutator(HideIgnoredFromAllDiscussionsPage::class), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addFilter(DiscussionSearcher::class, SubscriptionFilter::class) + ->addMutator(DiscussionSearcher::class, HideIgnoredFromAllDiscussionsPage::class), (new Extend\User()) ->registerPreference('flarum-subscriptions.notify_for_all_posts', 'boolval', false), diff --git a/extensions/subscriptions/src/Filter/SubscriptionFilter.php b/extensions/subscriptions/src/Filter/SubscriptionFilter.php index 2d3224a9fc..6e6f6d2f44 100644 --- a/extensions/subscriptions/src/Filter/SubscriptionFilter.php +++ b/extensions/subscriptions/src/Filter/SubscriptionFilter.php @@ -9,12 +9,16 @@ namespace Flarum\Subscriptions\Filter; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; use Flarum\User\User; use Illuminate\Database\Query\Builder; +/** + * @implements FilterInterface + */ class SubscriptionFilter implements FilterInterface { use ValidateFilterTrait; diff --git a/extensions/subscriptions/src/HideIgnoredFromAllDiscussionsPage.php b/extensions/subscriptions/src/HideIgnoredFromAllDiscussionsPage.php index 63c2eeffd2..2d36132c95 100644 --- a/extensions/subscriptions/src/HideIgnoredFromAllDiscussionsPage.php +++ b/extensions/subscriptions/src/HideIgnoredFromAllDiscussionsPage.php @@ -9,12 +9,12 @@ namespace Flarum\Subscriptions; +use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\SearchCriteria; -use Flarum\Search\SearchState; class HideIgnoredFromAllDiscussionsPage { - public function __invoke(SearchState $state, SearchCriteria $criteria): void + public function __invoke(DatabaseSearchState $state, SearchCriteria $criteria): void { // We only want to hide on the "all discussions" page. if (count($state->getActiveFilters()) === 0 && ! $state->isFulltextSearch()) { diff --git a/extensions/suspend/extend.php b/extensions/suspend/extend.php index 935ce1983b..fe4c1c87e3 100644 --- a/extensions/suspend/extend.php +++ b/extensions/suspend/extend.php @@ -10,6 +10,7 @@ use Flarum\Api\Serializer\BasicUserSerializer; use Flarum\Api\Serializer\UserSerializer; use Flarum\Extend; +use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\Suspend\Access\UserPolicy; use Flarum\Suspend\AddUserSuspendAttributes; use Flarum\Suspend\Event\Suspended; @@ -57,8 +58,8 @@ (new Extend\User()) ->permissionGroups(RevokeAccessFromSuspendedUsers::class), - (new Extend\SimpleFlarumSearch(UserSearcher::class)) - ->addFilter(SuspendedFilter::class), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addFilter(UserSearcher::class, SuspendedFilter::class), (new Extend\View()) ->namespace('flarum-suspend', __DIR__.'/views'), diff --git a/extensions/suspend/src/Query/SuspendedFilter.php b/extensions/suspend/src/Query/SuspendedFilter.php index b55c1d66aa..ed9080066e 100644 --- a/extensions/suspend/src/Query/SuspendedFilter.php +++ b/extensions/suspend/src/Query/SuspendedFilter.php @@ -10,12 +10,16 @@ namespace Flarum\Suspend\Query; use Carbon\Carbon; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\User\Guest; use Flarum\User\UserRepository; use Illuminate\Database\Query\Builder; +/** + * @implements FilterInterface + */ class SuspendedFilter implements FilterInterface { public function __construct( diff --git a/extensions/tags/extend.php b/extensions/tags/extend.php index 35b6a17ad7..f17c16904f 100644 --- a/extensions/tags/extend.php +++ b/extensions/tags/extend.php @@ -19,6 +19,7 @@ use Flarum\Http\RequestUtil; use Flarum\Post\Filter\PostSearcher; use Flarum\Post\Post; +use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\Tags\Access; use Flarum\Tags\Api\Controller; use Flarum\Tags\Api\Serializer\TagSerializer; @@ -134,15 +135,12 @@ ->listen(DiscussionWasTagged::class, Listener\CreatePostWhenTagsAreChanged::class) ->subscribe(Listener\UpdateTagMetadata::class), - (new Extend\SimpleFlarumSearch(PostSearcher::class)) - ->addFilter(PostTagFilter::class), - - (new Extend\SimpleFlarumSearch(DiscussionSearcher::class)) - ->addFilter(TagFilter::class) - ->addSearchMutator(HideHiddenTagsFromAllDiscussionsPage::class), - - (new Extend\SimpleFlarumSearch(TagSearcher::class)) - ->setFullTextFilter(FulltextFilter::class), + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addFilter(PostSearcher::class, PostTagFilter::class) + ->addFilter(DiscussionSearcher::class, TagFilter::class) + ->addMutator(DiscussionSearcher::class, HideHiddenTagsFromAllDiscussionsPage::class) + ->addSearcher(Tag::class, TagSearcher::class) + ->setFulltext(TagSearcher::class, FulltextFilter::class), (new Extend\ModelUrl(Tag::class)) ->addSlugDriver('default', Utf8SlugDriver::class), diff --git a/extensions/tags/src/Api/Controller/ListTagsController.php b/extensions/tags/src/Api/Controller/ListTagsController.php index 511c887dd4..2f23267a6a 100644 --- a/extensions/tags/src/Api/Controller/ListTagsController.php +++ b/extensions/tags/src/Api/Controller/ListTagsController.php @@ -13,8 +13,9 @@ use Flarum\Http\RequestUtil; use Flarum\Http\UrlGenerator; use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchManager; use Flarum\Tags\Api\Serializer\TagSerializer; -use Flarum\Tags\Search\TagSearcher; +use Flarum\Tags\Tag; use Flarum\Tags\TagRepository; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -35,7 +36,7 @@ class ListTagsController extends AbstractListController public function __construct( protected TagRepository $tags, - protected TagSearcher $searcher, + protected SearchManager $search, protected UrlGenerator $url ) { } @@ -53,7 +54,8 @@ protected function data(ServerRequestInterface $request, Document $document): it } if (array_key_exists('q', $filters)) { - $results = $this->searcher->search(new SearchCriteria($actor, $filters), $limit, $offset); + $results = $this->search->query(Tag::class, new SearchCriteria($actor, $filters, $limit, $offset)); + $tags = $results->getResults(); $document->addPaginationLinks( diff --git a/extensions/tags/src/Search/Filter/PostTagFilter.php b/extensions/tags/src/Search/Filter/PostTagFilter.php index b694990398..34506a41ef 100644 --- a/extensions/tags/src/Search/Filter/PostTagFilter.php +++ b/extensions/tags/src/Search/Filter/PostTagFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Tags\Search\Filter; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class PostTagFilter implements FilterInterface { use ValidateFilterTrait; diff --git a/extensions/tags/src/Search/Filter/TagFilter.php b/extensions/tags/src/Search/Filter/TagFilter.php index 38a6bdc3ee..7bc43e828c 100644 --- a/extensions/tags/src/Search/Filter/TagFilter.php +++ b/extensions/tags/src/Search/Filter/TagFilter.php @@ -10,7 +10,8 @@ namespace Flarum\Tags\Search\Filter; use Flarum\Http\SlugManager; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; use Flarum\Tags\Tag; @@ -18,6 +19,9 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Query\Builder; +/** + * @implements FilterInterface + */ class TagFilter implements FilterInterface { use ValidateFilterTrait; diff --git a/extensions/tags/src/Search/FulltextFilter.php b/extensions/tags/src/Search/FulltextFilter.php index af1cf2831a..adc71fe7cb 100644 --- a/extensions/tags/src/Search/FulltextFilter.php +++ b/extensions/tags/src/Search/FulltextFilter.php @@ -10,10 +10,14 @@ namespace Flarum\Tags\Search; use Flarum\Search\AbstractFulltextFilter; +use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\SearchState; use Flarum\Tags\TagRepository; use Illuminate\Database\Eloquent\Builder; +/** + * @extends AbstractFulltextFilter + */ class FulltextFilter extends AbstractFulltextFilter { public function __construct( @@ -30,12 +34,12 @@ private function getTagSearchSubQuery(string $searchValue): Builder ->orWhere('slug', 'like', "$searchValue%"); } - public function search(SearchState $state, string $query): void + public function search(SearchState $state, string $value): void { $state->getQuery() ->whereIn( 'id', - $this->getTagSearchSubQuery($query) + $this->getTagSearchSubQuery($value) ); } } diff --git a/extensions/tags/src/Search/HideHiddenTagsFromAllDiscussionsPage.php b/extensions/tags/src/Search/HideHiddenTagsFromAllDiscussionsPage.php index 59396c2888..9954602025 100644 --- a/extensions/tags/src/Search/HideHiddenTagsFromAllDiscussionsPage.php +++ b/extensions/tags/src/Search/HideHiddenTagsFromAllDiscussionsPage.php @@ -9,13 +9,13 @@ namespace Flarum\Tags\Search; +use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\SearchCriteria; -use Flarum\Search\SearchState; use Flarum\Tags\Tag; class HideHiddenTagsFromAllDiscussionsPage { - public function __invoke(SearchState $state, SearchCriteria $queryCriteria): void + public function __invoke(DatabaseSearchState $state, SearchCriteria $queryCriteria): void { if (count($state->getActiveFilters()) > 0 || $state->isFulltextSearch()) { return; diff --git a/extensions/tags/src/Search/TagSearcher.php b/extensions/tags/src/Search/TagSearcher.php index bfa9b1f8e8..9224030d71 100644 --- a/extensions/tags/src/Search/TagSearcher.php +++ b/extensions/tags/src/Search/TagSearcher.php @@ -9,14 +9,14 @@ namespace Flarum\Tags\Search; -use Flarum\Search\AbstractSearcher; +use Flarum\Search\Database\AbstractSearcher; use Flarum\Tags\Tag; use Flarum\User\User; use Illuminate\Database\Eloquent\Builder; class TagSearcher extends AbstractSearcher { - protected function getQuery(User $actor): Builder + public function getQuery(User $actor): Builder { return Tag::whereVisibleTo($actor)->select('tags.*'); } diff --git a/extensions/tags/tests/integration/api/tags/ListWithFulltextSearchTest.php b/extensions/tags/tests/integration/api/tags/ListWithFulltextSearchTest.php index 059e15da5e..d6ff6920ee 100644 --- a/extensions/tags/tests/integration/api/tags/ListWithFulltextSearchTest.php +++ b/extensions/tags/tests/integration/api/tags/ListWithFulltextSearchTest.php @@ -49,9 +49,9 @@ public function can_search_for_tags(string $search, array $expected) ]) ); - $data = json_decode($response->getBody()->getContents(), true)['data']; + $data = json_decode($contents = $response->getBody()->getContents(), true)['data'] ?? []; - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $contents); $this->assertEquals($expected, Arr::pluck($data, 'id')); } diff --git a/framework/core/js/src/common/query/discussions/CreatedGambit.ts b/framework/core/js/src/common/query/discussions/CreatedGambit.ts index 1b84ed044d..087196165d 100644 --- a/framework/core/js/src/common/query/discussions/CreatedGambit.ts +++ b/framework/core/js/src/common/query/discussions/CreatedGambit.ts @@ -2,7 +2,7 @@ import IGambit from '../IGambit'; export default class CreatedGambit implements IGambit { pattern(): string { - return 'created:(\\d{4}\\-\\d\\d\\-\\d\\d(?:\\.\\.\\d{4}\\-\\d\\d\\-\\d\\d))?'; + return 'created:(\\d{4}\\-\\d\\d\\-\\d\\d(?:\\.\\.(\\d{4}\\-\\d\\d\\-\\d\\d))?)'; } toFilter(matches: string[], negate: boolean): Record { diff --git a/framework/core/js/tests/unit/common/GambitManager.test.ts b/framework/core/js/tests/unit/common/GambitManager.test.ts index 50e40ed104..d27e1a6146 100644 --- a/framework/core/js/tests/unit/common/GambitManager.test.ts +++ b/framework/core/js/tests/unit/common/GambitManager.test.ts @@ -25,9 +25,9 @@ test('gambits are only applied for the correct resource type', function () { q: 'lorem created:2023-07-07 is:hidden author:behz', email: 'behz@machine.local', }); - expect(gambits.apply('discussions', { q: 'lorem created:2023-07-07 is:hidden -author:behz email:behz@machine.local' })).toStrictEqual({ + expect(gambits.apply('discussions', { q: 'lorem created:2023-07-07..2023-10-18 is:hidden -author:behz email:behz@machine.local' })).toStrictEqual({ q: 'lorem email:behz@machine.local', - created: '2023-07-07', + created: '2023-07-07..2023-10-18', hidden: true, '-author': ['behz'], }); diff --git a/framework/core/src/Api/Controller/ListAccessTokensController.php b/framework/core/src/Api/Controller/ListAccessTokensController.php index 8adec05452..98a3eeb14b 100644 --- a/framework/core/src/Api/Controller/ListAccessTokensController.php +++ b/framework/core/src/Api/Controller/ListAccessTokensController.php @@ -10,10 +10,11 @@ namespace Flarum\Api\Controller; use Flarum\Api\Serializer\AccessTokenSerializer; -use Flarum\Http\Filter\AccessTokenSearcher; +use Flarum\Http\AccessToken; use Flarum\Http\RequestUtil; use Flarum\Http\UrlGenerator; use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchManager; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -23,7 +24,7 @@ class ListAccessTokensController extends AbstractListController public function __construct( protected UrlGenerator $url, - protected AccessTokenSearcher $searcher + protected SearchManager $search ) { } @@ -37,7 +38,7 @@ protected function data(ServerRequestInterface $request, Document $document): it $limit = $this->extractLimit($request); $filter = $this->extractFilter($request); - $tokens = $this->searcher->search(new SearchCriteria($actor, $filter), $limit, $offset); + $tokens = $this->search->query(AccessToken::class, new SearchCriteria($actor, $filter, $limit, $offset)); $document->addPaginationLinks( $this->url->to('api')->route('access-tokens.index'), diff --git a/framework/core/src/Api/Controller/ListDiscussionsController.php b/framework/core/src/Api/Controller/ListDiscussionsController.php index b8eda4fa91..22043b7eb4 100644 --- a/framework/core/src/Api/Controller/ListDiscussionsController.php +++ b/framework/core/src/Api/Controller/ListDiscussionsController.php @@ -11,10 +11,10 @@ use Flarum\Api\Serializer\DiscussionSerializer; use Flarum\Discussion\Discussion; -use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Http\RequestUtil; use Flarum\Http\UrlGenerator; use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchManager; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -39,7 +39,7 @@ class ListDiscussionsController extends AbstractListController public array $sortFields = ['lastPostedAt', 'commentCount', 'createdAt']; public function __construct( - protected DiscussionSearcher $searcher, + protected SearchManager $search, protected UrlGenerator $url ) { } @@ -55,8 +55,10 @@ protected function data(ServerRequestInterface $request, Document $document): it $offset = $this->extractOffset($request); $include = array_merge($this->extractInclude($request), ['state']); - $criteria = new SearchCriteria($actor, $filters, $sort, $sortIsDefault); - $results = $this->searcher->search($criteria, $limit, $offset); + $results = $this->search->query( + Discussion::class, + new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault) + ); $document->addPaginationLinks( $this->url->to('api')->route('discussions.index'), diff --git a/framework/core/src/Api/Controller/ListGroupsController.php b/framework/core/src/Api/Controller/ListGroupsController.php index 93c8e9e2d8..935964e3b0 100644 --- a/framework/core/src/Api/Controller/ListGroupsController.php +++ b/framework/core/src/Api/Controller/ListGroupsController.php @@ -10,10 +10,11 @@ namespace Flarum\Api\Controller; use Flarum\Api\Serializer\GroupSerializer; -use Flarum\Group\Filter\GroupSearcher; +use Flarum\Group\Group; use Flarum\Http\RequestUtil; use Flarum\Http\UrlGenerator; use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchManager; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -26,7 +27,7 @@ class ListGroupsController extends AbstractListController public int $limit = -1; public function __construct( - protected GroupSearcher $searcher, + protected SearchManager $search, protected UrlGenerator $url ) { } @@ -42,9 +43,10 @@ protected function data(ServerRequestInterface $request, Document $document): it $limit = $this->extractLimit($request); $offset = $this->extractOffset($request); - $criteria = new SearchCriteria($actor, $filters, $sort, $sortIsDefault); - - $queryResults = $this->searcher->search($criteria, $limit, $offset); + $queryResults = $this->search->query( + Group::class, + new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault) + ); $document->addPaginationLinks( $this->url->to('api')->route('groups.index'), diff --git a/framework/core/src/Api/Controller/ListPostsController.php b/framework/core/src/Api/Controller/ListPostsController.php index 86a8fa5f42..4c419bb640 100644 --- a/framework/core/src/Api/Controller/ListPostsController.php +++ b/framework/core/src/Api/Controller/ListPostsController.php @@ -12,9 +12,10 @@ use Flarum\Api\Serializer\PostSerializer; use Flarum\Http\RequestUtil; use Flarum\Http\UrlGenerator; -use Flarum\Post\Filter\PostSearcher; +use Flarum\Post\Post; use Flarum\Post\PostRepository; use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchManager; use Illuminate\Support\Arr; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -35,7 +36,7 @@ class ListPostsController extends AbstractListController public array $sortFields = ['number', 'createdAt']; public function __construct( - protected PostSearcher $searcher, + protected SearchManager $search, protected PostRepository $posts, protected UrlGenerator $url ) { @@ -53,7 +54,10 @@ protected function data(ServerRequestInterface $request, Document $document): it $offset = $this->extractOffset($request); $include = $this->extractInclude($request); - $results = $this->searcher->search(new SearchCriteria($actor, $filters, $sort, $sortIsDefault), $limit, $offset); + $results = $this->search->query( + Post::class, + new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault) + ); $document->addPaginationLinks( $this->url->to('api')->route('posts.index'), diff --git a/framework/core/src/Api/Controller/ListUsersController.php b/framework/core/src/Api/Controller/ListUsersController.php index f89fa2785f..0547afc71b 100644 --- a/framework/core/src/Api/Controller/ListUsersController.php +++ b/framework/core/src/Api/Controller/ListUsersController.php @@ -13,7 +13,8 @@ use Flarum\Http\RequestUtil; use Flarum\Http\UrlGenerator; use Flarum\Search\SearchCriteria; -use Flarum\User\Search\UserSearcher; +use Flarum\Search\SearchManager; +use Flarum\User\User; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -32,7 +33,7 @@ class ListUsersController extends AbstractListController ]; public function __construct( - protected UserSearcher $searcher, + protected SearchManager $search, protected UrlGenerator $url ) { } @@ -58,8 +59,10 @@ protected function data(ServerRequestInterface $request, Document $document): it $offset = $this->extractOffset($request); $include = $this->extractInclude($request); - $criteria = new SearchCriteria($actor, $filters, $sort, $sortIsDefault); - $results = $this->searcher->search($criteria, $limit, $offset); + $results = $this->search->query( + User::class, + new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault) + ); $document->addPaginationLinks( $this->url->to('api')->route('users.index'), diff --git a/framework/core/src/Database/AbstractModel.php b/framework/core/src/Database/AbstractModel.php index 7a4679d752..812952fbc9 100644 --- a/framework/core/src/Database/AbstractModel.php +++ b/framework/core/src/Database/AbstractModel.php @@ -237,4 +237,14 @@ public function newCollection(array $models = []): Collection { return new Collection($models); } + + public function __sleep() + { + // Closures cannot be serialized. + // We should not need them if we are serializing a model. + $this->afterSaveCallbacks = []; + $this->afterDeleteCallbacks = []; + + return parent::__sleep(); + } } diff --git a/framework/core/src/Discussion/Discussion.php b/framework/core/src/Discussion/Discussion.php index 7cd4a1feb9..908e5231e2 100644 --- a/framework/core/src/Discussion/Discussion.php +++ b/framework/core/src/Discussion/Discussion.php @@ -85,6 +85,8 @@ class Discussion extends AbstractModel 'hidden_at' => 'datetime', ]; + protected $observables = ['hidden']; + /** * The user for which the state relationship should be loaded. */ @@ -143,6 +145,12 @@ public function hide(?User $actor = null): static $this->hidden_user_id = $actor?->id; $this->raise(new Hidden($this)); + + $this->saved(function (self $model) { + if ($model === $this) { + $model->fireModelEvent('hidden', false); + } + }); } return $this; @@ -155,6 +163,12 @@ public function restore(): static $this->hidden_user_id = null; $this->raise(new Restored($this)); + + $this->saved(function (self $model) { + if ($model === $this) { + $model->fireModelEvent('restored', false); + } + }); } return $this; diff --git a/framework/core/src/Discussion/Search/DiscussionSearcher.php b/framework/core/src/Discussion/Search/DiscussionSearcher.php index 1dfb59e660..a9438c5205 100644 --- a/framework/core/src/Discussion/Search/DiscussionSearcher.php +++ b/framework/core/src/Discussion/Search/DiscussionSearcher.php @@ -10,13 +10,13 @@ namespace Flarum\Discussion\Search; use Flarum\Discussion\Discussion; -use Flarum\Search\AbstractSearcher; +use Flarum\Search\Database\AbstractSearcher; use Flarum\User\User; use Illuminate\Database\Eloquent\Builder; class DiscussionSearcher extends AbstractSearcher { - protected function getQuery(User $actor): Builder + public function getQuery(User $actor): Builder { return Discussion::whereVisibleTo($actor)->select('discussions.*'); } diff --git a/framework/core/src/Discussion/Search/Filter/AuthorFilter.php b/framework/core/src/Discussion/Search/Filter/AuthorFilter.php index 3d77c73c48..ef5c44dc79 100644 --- a/framework/core/src/Discussion/Search/Filter/AuthorFilter.php +++ b/framework/core/src/Discussion/Search/Filter/AuthorFilter.php @@ -9,12 +9,16 @@ namespace Flarum\Discussion\Search\Filter; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; use Flarum\User\UserRepository; use Illuminate\Database\Query\Builder; +/** + * @implements FilterInterface + */ class AuthorFilter implements FilterInterface { use ValidateFilterTrait; diff --git a/framework/core/src/Discussion/Search/Filter/CreatedFilter.php b/framework/core/src/Discussion/Search/Filter/CreatedFilter.php index 3de0d6c516..c66bac114a 100644 --- a/framework/core/src/Discussion/Search/Filter/CreatedFilter.php +++ b/framework/core/src/Discussion/Search/Filter/CreatedFilter.php @@ -9,12 +9,16 @@ namespace Flarum\Discussion\Search\Filter; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; use Illuminate\Database\Query\Builder; use Illuminate\Support\Arr; +/** + * @implements FilterInterface + */ class CreatedFilter implements FilterInterface { use ValidateFilterTrait; diff --git a/framework/core/src/Discussion/Search/Filter/HiddenFilter.php b/framework/core/src/Discussion/Search/Filter/HiddenFilter.php index ced42868e7..e9e52cae86 100644 --- a/framework/core/src/Discussion/Search/Filter/HiddenFilter.php +++ b/framework/core/src/Discussion/Search/Filter/HiddenFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Discussion\Search\Filter; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Illuminate\Database\Query\Builder; +/** + * @implements FilterInterface + */ class HiddenFilter implements FilterInterface { public function getFilterKey(): string diff --git a/framework/core/src/Discussion/Search/Filter/UnreadFilter.php b/framework/core/src/Discussion/Search/Filter/UnreadFilter.php index a4b442bca7..6c12ac6359 100644 --- a/framework/core/src/Discussion/Search/Filter/UnreadFilter.php +++ b/framework/core/src/Discussion/Search/Filter/UnreadFilter.php @@ -10,11 +10,15 @@ namespace Flarum\Discussion\Search\Filter; use Flarum\Discussion\DiscussionRepository; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\User\User; use Illuminate\Database\Query\Builder; +/** + * @implements FilterInterface + */ class UnreadFilter implements FilterInterface { public function __construct( diff --git a/framework/core/src/Discussion/Search/FulltextFilter.php b/framework/core/src/Discussion/Search/FulltextFilter.php index 3aa4850dbe..da0b62a039 100644 --- a/framework/core/src/Discussion/Search/FulltextFilter.php +++ b/framework/core/src/Discussion/Search/FulltextFilter.php @@ -12,17 +12,22 @@ use Flarum\Discussion\Discussion; use Flarum\Post\Post; use Flarum\Search\AbstractFulltextFilter; +use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\SearchState; +use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Expression; +/** + * @extends AbstractFulltextFilter + */ class FulltextFilter extends AbstractFulltextFilter { - public function search(SearchState $state, string $query): void + public function search(SearchState $state, string $value): void { // 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 - $bit = preg_replace('/[^\p{L}\p{N}\p{M}_]+/u', ' ', $query); + $value = preg_replace('/[^\p{L}\p{N}\p{M}_]+/u', ' ', $value); $query = $state->getQuery(); $grammar = $query->getGrammar(); @@ -30,7 +35,7 @@ public function search(SearchState $state, string $query): void $discussionSubquery = Discussion::select('id') ->selectRaw('NULL as score') ->selectRaw('first_post_id as most_relevant_post_id') - ->whereRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (? IN BOOLEAN MODE)', [$bit]); + ->whereRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (? IN BOOLEAN MODE)', [$value]); // Construct a subquery to fetch discussions which contain relevant // posts. Retrieve the collective relevance of each discussion's posts, @@ -38,10 +43,10 @@ public function search(SearchState $state, string $query): void // the ID of the most relevant post. $subquery = Post::whereVisibleTo($state->getActor()) ->select('posts.discussion_id') - ->selectRaw('SUM(MATCH('.$grammar->wrap('posts.content').') AGAINST (?)) as score', [$bit]) - ->selectRaw('SUBSTRING_INDEX(GROUP_CONCAT('.$grammar->wrap('posts.id').' ORDER BY MATCH('.$grammar->wrap('posts.content').') AGAINST (?) DESC, '.$grammar->wrap('posts.number').'), \',\', 1) as most_relevant_post_id', [$bit]) + ->selectRaw('SUM(MATCH('.$grammar->wrap('posts.content').') AGAINST (?)) as score', [$value]) + ->selectRaw('SUBSTRING_INDEX(GROUP_CONCAT('.$grammar->wrap('posts.id').' ORDER BY MATCH('.$grammar->wrap('posts.content').') AGAINST (?) DESC, '.$grammar->wrap('posts.number').'), \',\', 1) as most_relevant_post_id', [$value]) ->where('posts.type', 'comment') - ->whereRaw('MATCH('.$grammar->wrap('posts.content').') AGAINST (? IN BOOLEAN MODE)', [$bit]) + ->whereRaw('MATCH('.$grammar->wrap('posts.content').') AGAINST (? IN BOOLEAN MODE)', [$value]) ->groupBy('posts.discussion_id') ->union($discussionSubquery); @@ -58,8 +63,8 @@ public function search(SearchState $state, string $query): void ->groupBy('discussions.id') ->addBinding($subquery->getBindings(), 'join'); - $state->setDefaultSort(function ($query) use ($grammar, $bit) { - $query->orderByRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (?) desc', [$bit]); + $state->setDefaultSort(function (Builder $query) use ($grammar, $value) { + $query->orderByRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (?) desc', [$value]); $query->orderBy('posts_ft.score', 'desc'); }); } diff --git a/framework/core/src/Extend/SearchDriver.php b/framework/core/src/Extend/SearchDriver.php new file mode 100644 index 0000000000..b0a5069ce4 --- /dev/null +++ b/framework/core/src/Extend/SearchDriver.php @@ -0,0 +1,153 @@ + $driverClass: The driver class you are modifying or adding. + * This driver must extend \Flarum\Search\AbstractDriver. + */ + public function __construct( + private readonly string $driverClass + ) { + } + + /** + * Add a filter to this searcher. Filters are used to filter search queries. + * + * @param class-string $modelClass : The class of the model subject to searching/filtering. + * This model must extend \Flarum\Database\AbstractModel. + * @param class-string $searcherClass : The class of the Searcher for this model + * This searcher must implement \Flarum\Search\SearcherInterface. + * Or extend \Flarum\Search\Database\AbstractSearcher if using the default driver. + * @return self + */ + public function addSearcher(string $modelClass, string $searcherClass): self + { + $this->searchers[$modelClass] = $searcherClass; + + return $this; + } + + /** + * Add a filter to this searcher. Filters are used to filter search queries. + * + * @param class-string $searcherClass : The class of the Searcher for this model + * This searcher must implement \Flarum\Search\SearcherInterface. + * Or extend \Flarum\Search\Database\AbstractSearcher if using the default driver. + * @param class-string $filterClass: The ::class attribute of the filter you are adding. + * This filter must implement \Flarum\Search\FilterInterface + * @return self + */ + public function addFilter(string $searcherClass, string $filterClass): self + { + $this->filters[$searcherClass][] = $filterClass; + + return $this; + } + + /** + * Set the full text filter for this searcher. The full text filter actually executes the search. + * + * @param class-string $searcherClass : The class of the Searcher for this model + * This searcher must implement \Flarum\Search\SearcherInterface. + * Or extend \Flarum\Search\Database\AbstractSearcher if using the default driver. + * @param class-string $fulltextClass: The ::class attribute of the full test filter you are adding. + * This filter must implement \Flarum\Search\FilterInterface + * @return self + */ + public function setFulltext(string $searcherClass, string $fulltextClass): self + { + $this->fulltext[$searcherClass] = $fulltextClass; + + return $this; + } + + /** + * Add a callback through which to run all search queries after filters have been applied. + * + * @param class-string $searcherClass : The class of the Searcher for this model + * This searcher must implement \Flarum\Search\SearcherInterface. + * Or extend \Flarum\Search\Database\AbstractSearcher if using the default driver. + * @param (callable(SearchState $search, SearchCriteria $criteria): void)|class-string $callback + * + * The callback can be a closure or an invokable class, and should accept: + * - \Flarum\Search\SearchState $search + * - \Flarum\Query\QueryCriteria $criteria + * + * The callback should return void. + * + * @return self + */ + public function addMutator(string $searcherClass, callable|string $callback): self + { + $this->mutators[$searcherClass][] = $callback; + + return $this; + } + + public function extend(Container $container, Extension $extension = null): void + { + $container->extend('flarum.search.drivers', function (array $oldDrivers) { + $oldDrivers[$this->driverClass] = array_merge( + $oldDrivers[$this->driverClass] ?? [], + $this->searchers + ); + + return $oldDrivers; + }); + + $container->extend('flarum.search.fulltext', function (array $oldFulltextFilters) { + foreach ($this->fulltext as $searcherClass => $fulltextClass) { + $oldFulltextFilters[$searcherClass] = $fulltextClass; + } + + return $oldFulltextFilters; + }); + + $container->extend('flarum.search.filters', function (array $oldFilters) { + foreach ($this->filters as $searcherClass => $filters) { + $oldFilters[$searcherClass] = array_merge( + $oldFilters[$searcherClass] ?? [], + $filters + ); + } + + return $oldFilters; + }); + + $container->extend('flarum.search.mutators', function (array $oldMutators) { + foreach ($this->mutators as $searcherClass => $mutators) { + $oldMutators[$searcherClass] = array_merge( + $oldMutators[$searcherClass] ?? [], + $mutators + ); + } + + return $oldMutators; + }); + } +} diff --git a/framework/core/src/Extend/SearchIndex.php b/framework/core/src/Extend/SearchIndex.php new file mode 100644 index 0000000000..277ad55f8b --- /dev/null +++ b/framework/core/src/Extend/SearchIndex.php @@ -0,0 +1,50 @@ +indexers[$resourceClass][] = $indexerClass; + + return $this; + } + + public function extend(Container $container, Extension $extension = null): void + { + if (empty($this->indexers)) { + return; + } + + $container->extend('flarum.search.indexers', function (array $indexers) { + foreach ($this->indexers as $resourceClass => $indexerClasses) { + $indexers[$resourceClass] = array_merge( + $indexers[$resourceClass] ?? [], + $indexerClasses + ); + } + + return $indexers; + }); + } +} diff --git a/framework/core/src/Extend/SimpleFlarumSearch.php b/framework/core/src/Extend/SimpleFlarumSearch.php deleted file mode 100644 index 1fba81fcef..0000000000 --- a/framework/core/src/Extend/SimpleFlarumSearch.php +++ /dev/null @@ -1,112 +0,0 @@ - $searcher: The ::class attribute of the Searcher you are modifying. - * This searcher must extend \Flarum\Search\AbstractSearcher. - */ - public function __construct( - private readonly string $searcher - ) { - } - - /** - * Add a filter to this searcher. Filters are used to filter search queries. - * - * @param class-string $filterClass: The ::class attribute of the filter you are adding. - * This filter must implement \Flarum\Search\FilterInterface - * @return self - */ - public function addFilter(string $filterClass): self - { - $this->filters[] = $filterClass; - - return $this; - } - - /** - * Set the full text filter for this searcher. The full text filter actually executes the search. - * - * @param class-string $fulltextClass: The ::class attribute of the full test filter you are adding. - * This filter must implement \Flarum\Search\FilterInterface - * @return self - */ - public function setFullTextFilter(string $fulltextClass): self - { - $this->fullTextFilter = $fulltextClass; - - return $this; - } - - /** - * Add a callback through which to run all search queries after filters have been applied. - * - * @param (callable(SearchState $search, SearchCriteria $criteria): void)|class-string $callback - * - * The callback can be a closure or an invokable class, and should accept: - * - \Flarum\Search\SearchState $search - * - \Flarum\Query\QueryCriteria $criteria - * - * The callback should return void. - * - * @return self - */ - public function addSearchMutator(callable|string $callback): self - { - $this->searchMutators[] = $callback; - - return $this; - } - - public function extend(Container $container, Extension $extension = null): void - { - if (! is_null($this->fullTextFilter)) { - $container->extend('flarum.simple_search.fulltext_filters', function (array $oldFulltextFilters) { - $oldFulltextFilters[$this->searcher] = $this->fullTextFilter; - - return $oldFulltextFilters; - }); - } - - $container->extend('flarum.simple_search.filters', function (array $oldFilters) { - // We need the key to be set, even if there are no filters, so that the searcher is registered. - $oldFilters[$this->searcher] = $oldFilters[$this->searcher] ?? []; - - foreach ($this->filters as $filter) { - $oldFilters[$this->searcher][] = $filter; - } - - return $oldFilters; - }); - - $container->extend('flarum.simple_search.search_mutators', function (array $oldMutators) { - foreach ($this->searchMutators as $mutator) { - $oldMutators[$this->searcher][] = $mutator; - } - - return $oldMutators; - }); - } -} diff --git a/framework/core/src/Group/Filter/GroupSearcher.php b/framework/core/src/Group/Filter/GroupSearcher.php index 170a7d40cc..45569dccea 100644 --- a/framework/core/src/Group/Filter/GroupSearcher.php +++ b/framework/core/src/Group/Filter/GroupSearcher.php @@ -10,13 +10,13 @@ namespace Flarum\Group\Filter; use Flarum\Group\Group; -use Flarum\Search\AbstractSearcher; +use Flarum\Search\Database\AbstractSearcher; use Flarum\User\User; use Illuminate\Database\Eloquent\Builder; class GroupSearcher extends AbstractSearcher { - protected function getQuery(User $actor): Builder + public function getQuery(User $actor): Builder { return Group::whereVisibleTo($actor)->select('groups.*'); } diff --git a/framework/core/src/Group/Filter/HiddenFilter.php b/framework/core/src/Group/Filter/HiddenFilter.php index bc81a0671d..9a4060358b 100644 --- a/framework/core/src/Group/Filter/HiddenFilter.php +++ b/framework/core/src/Group/Filter/HiddenFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Group\Filter; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class HiddenFilter implements FilterInterface { use ValidateFilterTrait; diff --git a/framework/core/src/Http/Filter/AccessTokenSearcher.php b/framework/core/src/Http/Filter/AccessTokenSearcher.php index ab2ecc6bc5..8fd3d9d9a9 100644 --- a/framework/core/src/Http/Filter/AccessTokenSearcher.php +++ b/framework/core/src/Http/Filter/AccessTokenSearcher.php @@ -10,13 +10,13 @@ namespace Flarum\Http\Filter; use Flarum\Http\AccessToken; -use Flarum\Search\AbstractSearcher; +use Flarum\Search\Database\AbstractSearcher; use Flarum\User\User; use Illuminate\Database\Eloquent\Builder; class AccessTokenSearcher extends AbstractSearcher { - protected function getQuery(User $actor): Builder + public function getQuery(User $actor): Builder { return AccessToken::query()->whereVisibleTo($actor); } diff --git a/framework/core/src/Http/Filter/UserFilter.php b/framework/core/src/Http/Filter/UserFilter.php index d897b4c97b..216151abff 100644 --- a/framework/core/src/Http/Filter/UserFilter.php +++ b/framework/core/src/Http/Filter/UserFilter.php @@ -10,7 +10,8 @@ namespace Flarum\Http\Filter; use Flarum\Api\Controller\ListAccessTokensController; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; @@ -18,6 +19,7 @@ * Filters an access tokens request by the related user. * * @see ListAccessTokensController + * @implements FilterInterface */ class UserFilter implements FilterInterface { diff --git a/framework/core/src/Install/Steps/WriteSettings.php b/framework/core/src/Install/Steps/WriteSettings.php index 28dd0c70e1..a1fa2ab634 100644 --- a/framework/core/src/Install/Steps/WriteSettings.php +++ b/framework/core/src/Install/Steps/WriteSettings.php @@ -59,7 +59,7 @@ private function getDefaults(): array 'forum_description' => '', 'mail_driver' => 'mail', 'mail_from' => 'noreply@localhost', - 'slug_driver_Flarum\User\User' => 'default', + 'slug_driver_Flarum\User\User' => 'default', // @todo: use a morph map instead `User::class => 'user'` = slug_driver_user (below as well) 'theme_colored_header' => '0', 'theme_dark_mode' => '0', 'theme_primary_color' => '#4D698E', diff --git a/framework/core/src/Post/CommentPost.php b/framework/core/src/Post/CommentPost.php index c1b150ddb3..c93216f65a 100644 --- a/framework/core/src/Post/CommentPost.php +++ b/framework/core/src/Post/CommentPost.php @@ -28,6 +28,8 @@ class CommentPost extends Post public static string $type = 'comment'; protected static Formatter $formatter; + protected $observables = ['hidden']; + public static function reply(int $discussionId, string $content, int $userId, ?string $ipAddress, ?User $actor = null): static { $post = new static; @@ -69,6 +71,12 @@ public function hide(?User $actor = null): static $this->hidden_user_id = $actor?->id; $this->raise(new Hidden($this)); + + $this->saved(function (self $model) { + if ($model === $this) { + $model->fireModelEvent('hidden', false); + } + }); } return $this; @@ -81,6 +89,12 @@ public function restore(): static $this->hidden_user_id = null; $this->raise(new Restored($this)); + + $this->saved(function (self $model) { + if ($model === $this) { + $model->fireModelEvent('restored', false); + } + }); } return $this; diff --git a/framework/core/src/Post/Filter/AuthorFilter.php b/framework/core/src/Post/Filter/AuthorFilter.php index 878470d083..f4bebf67c7 100644 --- a/framework/core/src/Post/Filter/AuthorFilter.php +++ b/framework/core/src/Post/Filter/AuthorFilter.php @@ -9,11 +9,15 @@ namespace Flarum\Post\Filter; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; use Flarum\User\UserRepository; +/** + * @implements FilterInterface + */ class AuthorFilter implements FilterInterface { use ValidateFilterTrait; diff --git a/framework/core/src/Post/Filter/DiscussionFilter.php b/framework/core/src/Post/Filter/DiscussionFilter.php index 7ce9b59169..dac9523886 100644 --- a/framework/core/src/Post/Filter/DiscussionFilter.php +++ b/framework/core/src/Post/Filter/DiscussionFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Post\Filter; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class DiscussionFilter implements FilterInterface { use ValidateFilterTrait; diff --git a/framework/core/src/Post/Filter/IdFilter.php b/framework/core/src/Post/Filter/IdFilter.php index 1680f30b2e..e6b1befcfa 100644 --- a/framework/core/src/Post/Filter/IdFilter.php +++ b/framework/core/src/Post/Filter/IdFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Post\Filter; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class IdFilter implements FilterInterface { use ValidateFilterTrait; diff --git a/framework/core/src/Post/Filter/NumberFilter.php b/framework/core/src/Post/Filter/NumberFilter.php index 48d06702e9..cab7f37d78 100644 --- a/framework/core/src/Post/Filter/NumberFilter.php +++ b/framework/core/src/Post/Filter/NumberFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Post\Filter; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class NumberFilter implements FilterInterface { use ValidateFilterTrait; diff --git a/framework/core/src/Post/Filter/PostSearcher.php b/framework/core/src/Post/Filter/PostSearcher.php index 4b0d6dc9d9..46e510dc5c 100644 --- a/framework/core/src/Post/Filter/PostSearcher.php +++ b/framework/core/src/Post/Filter/PostSearcher.php @@ -10,13 +10,13 @@ namespace Flarum\Post\Filter; use Flarum\Post\Post; -use Flarum\Search\AbstractSearcher; +use Flarum\Search\Database\AbstractSearcher; use Flarum\User\User; use Illuminate\Database\Eloquent\Builder; class PostSearcher extends AbstractSearcher { - protected function getQuery(User $actor): Builder + public function getQuery(User $actor): Builder { return Post::whereVisibleTo($actor)->select('posts.*'); } diff --git a/framework/core/src/Post/Filter/TypeFilter.php b/framework/core/src/Post/Filter/TypeFilter.php index 5dd00bb2df..5c9010f64e 100644 --- a/framework/core/src/Post/Filter/TypeFilter.php +++ b/framework/core/src/Post/Filter/TypeFilter.php @@ -9,10 +9,14 @@ namespace Flarum\Post\Filter; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; +/** + * @implements FilterInterface + */ class TypeFilter implements FilterInterface { use ValidateFilterTrait; diff --git a/framework/core/src/Search/AbstractDriver.php b/framework/core/src/Search/AbstractDriver.php new file mode 100644 index 0000000000..19dc23250a --- /dev/null +++ b/framework/core/src/Search/AbstractDriver.php @@ -0,0 +1,42 @@ +, class-string> + */ + protected array $searchers, + protected Container $container + ) { + } + + abstract public static function name(): string; + + public function getSearchers(): array + { + return $this->searchers; + } + + public function supports(string $modelClass): bool + { + return isset($this->searchers[$modelClass]); + } + + public function searcher(string $resourceClass): SearcherInterface + { + return $this->container->make($this->searchers[$resourceClass]); + } +} diff --git a/framework/core/src/Search/AbstractFulltextFilter.php b/framework/core/src/Search/AbstractFulltextFilter.php index b1b6f07a85..c9c6f5b830 100644 --- a/framework/core/src/Search/AbstractFulltextFilter.php +++ b/framework/core/src/Search/AbstractFulltextFilter.php @@ -9,6 +9,12 @@ namespace Flarum\Search; +use Flarum\Search\Filter\FilterInterface; + +/** + * @template TState of SearchState + * @implements FilterInterface + */ abstract class AbstractFulltextFilter implements FilterInterface { public function getFilterKey(): string @@ -21,5 +27,8 @@ public function filter(SearchState $state, array|string $value, bool $negate): v $this->search($state, $value); } - abstract public function search(SearchState $state, string $query): void; + /** + * @param TState $state + */ + abstract public function search(SearchState $state, string $value): void; } diff --git a/framework/core/src/Search/AbstractSearcher.php b/framework/core/src/Search/Database/AbstractSearcher.php similarity index 54% rename from framework/core/src/Search/AbstractSearcher.php rename to framework/core/src/Search/Database/AbstractSearcher.php index 83b4b03a41..1f29681b47 100644 --- a/framework/core/src/Search/AbstractSearcher.php +++ b/framework/core/src/Search/Database/AbstractSearcher.php @@ -7,13 +7,16 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Search; +namespace Flarum\Search\Database; +use Flarum\Search\Filter\FilterManager; +use Flarum\Search\SearchCriteria; +use Flarum\Search\SearcherInterface; +use Flarum\Search\SearchResults; use Flarum\User\User; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Str; -abstract class AbstractSearcher +abstract class AbstractSearcher implements SearcherInterface { public function __construct( protected FilterManager $filters, @@ -22,21 +25,20 @@ public function __construct( ) { } - abstract protected function getQuery(User $actor): Builder; - - public function search(SearchCriteria $criteria, ?int $limit = null, int $offset = 0): SearchResults + public function search(SearchCriteria $criteria): SearchResults { $actor = $criteria->actor; $query = $this->getQuery($actor); - $search = new SearchState($query->getQuery(), $actor, in_array('q', array_keys($criteria->filters), true)); + $search = new DatabaseSearchState($actor, $criteria->isFulltext()); + $search->setQuery($query->getQuery()); $this->filters->apply($search, $criteria->filters); $this->applySort($search, $criteria->sort, $criteria->sortIsDefault); - $this->applyOffset($search, $offset); - $this->applyLimit($search, $limit + 1); + $this->applyOffset($search, $criteria->offset); + $this->applyLimit($search, $criteria->limit + 1); foreach ($this->mutators as $mutator) { $mutator($search, $criteria); @@ -47,45 +49,45 @@ public function search(SearchCriteria $criteria, ?int $limit = null, int $offset // results. If there are, we will get rid of that extra result. $results = $query->get(); - if ($areMoreResults = $limit > 0 && $results->count() > $limit) { + if ($areMoreResults = $criteria->limit > 0 && $results->count() > $criteria->limit) { $results->pop(); } return new SearchResults($results, $areMoreResults); } - protected function applySort(SearchState $query, ?array $sort = null, bool $sortIsDefault = false): void + protected function applySort(DatabaseSearchState $state, ?array $sort = null, bool $sortIsDefault = false): void { - if ($sortIsDefault && ! empty($query->getDefaultSort())) { - $sort = $query->getDefaultSort(); + if ($sortIsDefault && ! empty($state->getDefaultSort())) { + $sort = $state->getDefaultSort(); } if (is_callable($sort)) { - $sort($query->getQuery()); + $sort($state->getQuery()); } else { foreach ((array) $sort as $field => $order) { if (is_array($order)) { foreach ($order as $value) { - $query->getQuery()->orderByRaw(Str::snake($field).' != ?', [$value]); + $state->getQuery()->orderByRaw(Str::snake($field).' != ?', [$value]); } } else { - $query->getQuery()->orderBy(Str::snake($field), $order); + $state->getQuery()->orderBy(Str::snake($field), $order); } } } } - protected function applyOffset(SearchState $query, int $offset): void + protected function applyOffset(DatabaseSearchState $state, int $offset): void { if ($offset > 0) { - $query->getQuery()->skip($offset); + $state->getQuery()->skip($offset); } } - protected function applyLimit(SearchState $query, ?int $limit): void + protected function applyLimit(DatabaseSearchState $state, ?int $limit): void { if ($limit > 0) { - $query->getQuery()->take($limit); + $state->getQuery()->take($limit); } } } diff --git a/framework/core/src/Search/Database/DatabaseSearchDriver.php b/framework/core/src/Search/Database/DatabaseSearchDriver.php new file mode 100644 index 0000000000..c3c27f5a29 --- /dev/null +++ b/framework/core/src/Search/Database/DatabaseSearchDriver.php @@ -0,0 +1,20 @@ +query = $query; + } + + public function getQuery(): Builder + { + return $this->query; + } +} diff --git a/framework/core/src/Search/FilterInterface.php b/framework/core/src/Search/Filter/FilterInterface.php similarity index 77% rename from framework/core/src/Search/FilterInterface.php rename to framework/core/src/Search/Filter/FilterInterface.php index 0fd9d1e292..47592b3f70 100644 --- a/framework/core/src/Search/FilterInterface.php +++ b/framework/core/src/Search/Filter/FilterInterface.php @@ -7,8 +7,13 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Search; +namespace Flarum\Search\Filter; +use Flarum\Search\SearchState; + +/** + * @template TState of SearchState + */ interface FilterInterface { /** @@ -18,6 +23,8 @@ public function getFilterKey(): string; /** * Filters a query. + * + * @param TState $state */ public function filter(SearchState $state, string|array $value, bool $negate): void; } diff --git a/framework/core/src/Search/FilterManager.php b/framework/core/src/Search/Filter/FilterManager.php similarity index 75% rename from framework/core/src/Search/FilterManager.php rename to framework/core/src/Search/Filter/FilterManager.php index 2f21dc2868..8164ab86a9 100644 --- a/framework/core/src/Search/FilterManager.php +++ b/framework/core/src/Search/Filter/FilterManager.php @@ -7,7 +7,10 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Search; +namespace Flarum\Search\Filter; + +use Flarum\Search\AbstractFulltextFilter; +use Flarum\Search\SearchState; class FilterManager { @@ -17,7 +20,7 @@ class FilterManager protected array $filters = []; public function __construct( - protected ?AbstractFulltextFilter $fulltextGambit = null + protected ?AbstractFulltextFilter $fulltext = null ) { } @@ -51,9 +54,14 @@ protected function applyFilters(SearchState $search, array $filters): void protected function applyFulltext(SearchState $search, ?string $query): void { - if ($this->fulltextGambit && $query) { - $search->addActiveFilter($this->fulltextGambit); - $this->fulltextGambit->search($search, $query); + if ($this->fulltext && $query) { + $search->addActiveFilter($this->fulltext); + $this->fulltext->search($search, $query); } } + + public function getFulltext(): ?AbstractFulltextFilter + { + return $this->fulltext; + } } diff --git a/framework/core/src/Search/IndexerInterface.php b/framework/core/src/Search/IndexerInterface.php new file mode 100644 index 0000000000..6d229ef820 --- /dev/null +++ b/framework/core/src/Search/IndexerInterface.php @@ -0,0 +1,37 @@ +models)) { + return; + } + + $indexer = $container->make($this->indexerClass); + + $indexer->{$this->operation}($this->models); + } +} diff --git a/framework/core/src/Search/Listener/ModelObserver.php b/framework/core/src/Search/Listener/ModelObserver.php new file mode 100644 index 0000000000..58bcf0f712 --- /dev/null +++ b/framework/core/src/Search/Listener/ModelObserver.php @@ -0,0 +1,65 @@ +runIndexJob($model, IndexJob::SAVE); + } + + public function updated(AbstractModel $model): void + { + $this->runIndexJob($model, IndexJob::SAVE); + } + + public function hidden(AbstractModel $model): void + { + $this->runIndexJob($model, IndexJob::DELETE); + } + + public function deleted(AbstractModel $model): void + { + $this->runIndexJob($model, IndexJob::DELETE); + } + + public function forceDeleted(AbstractModel $model): void + { + $this->runIndexJob($model, IndexJob::DELETE); + } + + public function restored(AbstractModel $model): void + { + $this->runIndexJob($model, IndexJob::SAVE); + } + + private function runIndexJob(AbstractModel $model, string $operation): void + { + if ($this->search->indexable($model::class)) { + foreach ($this->search->indexers($model::class) as $indexerClass) { + $queue = property_exists($indexerClass, 'queue') ? $indexerClass::$queue : null; + + $this->queue->pushOn($queue, new IndexJob($indexerClass, [$model], $operation)); + } + } + } +} diff --git a/framework/core/src/Search/SearchCriteria.php b/framework/core/src/Search/SearchCriteria.php index dfd8fc08a0..c13f408d1c 100644 --- a/framework/core/src/Search/SearchCriteria.php +++ b/framework/core/src/Search/SearchCriteria.php @@ -21,6 +21,8 @@ class SearchCriteria public function __construct( public User $actor, public array $filters, + public ?int $limit = null, + public int $offset = 0, /** * 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 @@ -33,7 +35,12 @@ public function __construct( * Is the sort for this request the default sort from the controller? * If false, the current request specifies a sort. */ - public bool $sortIsDefault = false + public bool $sortIsDefault = false, ) { } + + public function isFulltext(): bool + { + return in_array('q', array_keys($this->filters), true); + } } diff --git a/framework/core/src/Search/SearchManager.php b/framework/core/src/Search/SearchManager.php new file mode 100644 index 0000000000..dfaafaa31a --- /dev/null +++ b/framework/core/src/Search/SearchManager.php @@ -0,0 +1,71 @@ +> */ + protected array $drivers, + /** @var array, array>> */ + protected array $indexers, + protected SettingsRepositoryInterface $settings, + protected Container $container + ) { + } + + public function driver(?string $name): AbstractDriver + { + $driver = Arr::first($this->drivers, fn ($driver) => $driver::name() === $name); + + if (! $driver) { + return $this->driver(DatabaseSearchDriver::name()); + } + + return $this->container->make($driver); + } + + public function driverFor(string $resourceClass): AbstractDriver + { + return $this->driver($this->settings->get("search_driver_$resourceClass")); + } + + /** + * @param class-string $resourceClass + * @return array> + */ + public function indexers(string $resourceClass): array + { + return $this->indexers[$resourceClass] ?? []; + } + + public function indexable(string $resourceClass): bool + { + return ! empty($this->indexers[$resourceClass]); + } + + public function query(string $resourceClass, SearchCriteria $criteria): SearchResults + { + $driver = $this->driverFor($resourceClass); + $defaultDriver = $this->driver(DatabaseSearchDriver::name()); + + if ($criteria->isFulltext() || ! $defaultDriver->supports($resourceClass)) { + return $driver->searcher($resourceClass)->search($criteria); + } + + return $defaultDriver->searcher($resourceClass)->search($criteria); + } +} diff --git a/framework/core/src/Search/SearchServiceProvider.php b/framework/core/src/Search/SearchServiceProvider.php index ea9251b13b..95c1452f94 100644 --- a/framework/core/src/Search/SearchServiceProvider.php +++ b/framework/core/src/Search/SearchServiceProvider.php @@ -9,6 +9,7 @@ namespace Flarum\Search; +use Flarum\Discussion\Discussion; use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Discussion\Search\Filter as DiscussionFilter; use Flarum\Discussion\Search\FulltextFilter as DiscussionFulltextFilter; @@ -16,28 +17,59 @@ use Flarum\Foundation\ContainerUtil; use Flarum\Group\Filter as GroupFilter; use Flarum\Group\Filter\GroupSearcher; +use Flarum\Group\Group; +use Flarum\Http\AccessToken; use Flarum\Http\Filter\AccessTokenSearcher; use Flarum\Http\Filter as HttpFilter; use Flarum\Post\Filter as PostFilter; use Flarum\Post\Filter\PostSearcher; +use Flarum\Post\Post; +use Flarum\Search\Filter\FilterManager; +use Flarum\Search\Listener\ModelObserver; +use Flarum\Settings\SettingsRepositoryInterface; use Flarum\User\Search\Filter as UserFilter; use Flarum\User\Search\FulltextFilter as UserFulltextFilter; use Flarum\User\Search\UserSearcher; +use Flarum\User\User; use Illuminate\Contracts\Container\Container; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; class SearchServiceProvider extends AbstractServiceProvider { public function register(): void { - $this->container->singleton('flarum.simple_search.fulltext_filters', function () { + $this->container->singleton('flarum.search', function (Container $container) { + return new SearchManager( + array_keys($container->make('flarum.search.drivers')), + $container->make('flarum.search.indexers'), + $container->make(SettingsRepositoryInterface::class), + $container, + ); + }); + + $this->container->alias('flarum.search', SearchManager::class); + + $this->container->singleton('flarum.search.drivers', function () { + return [ + Database\DatabaseSearchDriver::class => [ + Discussion::class => DiscussionSearcher::class, + User::class => UserSearcher::class, + Post::class => PostSearcher::class, + Group::class => GroupSearcher::class, + AccessToken::class => AccessTokenSearcher::class, + ], + ]; + }); + + $this->container->singleton('flarum.search.fulltext', function () { return [ DiscussionSearcher::class => DiscussionFulltextFilter::class, UserSearcher::class => UserFulltextFilter::class ]; }); - $this->container->singleton('flarum.simple_search.filters', function () { + $this->container->singleton('flarum.search.filters', function () { return [ AccessTokenSearcher::class => [ HttpFilter\UserFilter::class, @@ -65,42 +97,65 @@ public function register(): void ]; }); - $this->container->singleton('flarum.simple_search.search_mutators', function () { + $this->container->singleton('flarum.search.mutators', function () { return []; }); + + // Indexers aren't driver specific. + // For example, a search driver implementation may support searching discussions, + // and would need to index discussions for that, but it would also need to index + // posts without supporting searching them, because it needs to index the posts for + // searching discussions. + $this->container->singleton('flarum.search.indexers', function () { + return [ + // Model::class => [...], + ]; + }); } public function boot(Container $container): void { - foreach ($container->make('flarum.simple_search.filters') as $searcher => $filterClasses) { + foreach ($container->make('flarum.search.drivers') as $driverClass => $searchers) { $container - ->when($searcher) - ->needs(FilterManager::class) - ->give(function () use ($container, $searcher) { - $fulltext = $container->make('flarum.simple_search.fulltext_filters'); - $fulltextClass = $fulltext[$searcher] ?? null; + ->when($driverClass) + ->needs('$searchers') + ->give($searchers); - $manager = new FilterManager( - $fulltextClass ? $container->make($fulltextClass) : null - ); + foreach ($searchers as $searcher) { + $container + ->when($searcher) + ->needs(FilterManager::class) + ->give(function () use ($container, $searcher) { + $fulltext = $container->make('flarum.search.fulltext'); + $fulltextClass = $fulltext[$searcher] ?? null; - foreach (Arr::get($container->make('flarum.simple_search.filters'), $searcher, []) as $filter) { - $manager->add($container->make($filter)); - } + $manager = new FilterManager( + $fulltextClass ? $container->make($fulltextClass) : null + ); - return $manager; - }); + foreach (Arr::get($container->make('flarum.search.filters'), $searcher, []) as $filter) { + $manager->add($container->make($filter)); + } - $container - ->when($searcher) - ->needs('$mutators') - ->give(function () use ($container, $searcher) { - $searchMutators = Arr::get($container->make('flarum.simple_search.search_mutators'), $searcher, []); - - return array_map(function ($mutator) { - return ContainerUtil::wrapCallback($mutator, $this->container); - }, $searchMutators); - }); + return $manager; + }); + + $container + ->when($searcher) + ->needs('$mutators') + ->give(function () use ($container, $searcher) { + $searchMutators = Arr::get($container->make('flarum.search.mutators'), $searcher, []); + + return array_map(function ($mutator) { + return ContainerUtil::wrapCallback($mutator, $this->container); + }, $searchMutators); + }); + } + } + + /** @var \Flarum\Database\AbstractModel $modelClass */ + foreach ($container->make('flarum.search.indexers') as $modelClass => $indexers) { + $modelClass::observe(ModelObserver::class); } } } diff --git a/framework/core/src/Search/SearchState.php b/framework/core/src/Search/SearchState.php index 24b4aad42e..a505f980d5 100644 --- a/framework/core/src/Search/SearchState.php +++ b/framework/core/src/Search/SearchState.php @@ -10,8 +10,8 @@ namespace Flarum\Search; use Closure; +use Flarum\Search\Filter\FilterInterface; use Flarum\User\User; -use Illuminate\Database\Query\Builder; class SearchState { @@ -20,10 +20,11 @@ class SearchState */ protected array $activeFilters = []; - public function __construct( - protected Builder $query, + final public function __construct( protected User $actor, - /** Whether this is a fulltext search or just filtering. */ + /** + * Whether this is a fulltext search or just filtering. + */ protected bool $fulltextSearch, /** * An array of sort-order pairs, where the column @@ -37,14 +38,6 @@ public function __construct( ) { } - /** - * Get the query builder for the search results query. - */ - public function getQuery(): Builder - { - return $this->query; - } - public function getActor(): User { return $this->actor; diff --git a/framework/core/src/Search/SearcherInterface.php b/framework/core/src/Search/SearcherInterface.php new file mode 100644 index 0000000000..832d5f5bba --- /dev/null +++ b/framework/core/src/Search/SearcherInterface.php @@ -0,0 +1,20 @@ + '#4D698E', 'theme_secondary_color' => '#4D698E', + 'search_driver_Flarum\User\User' => 'default', // @todo: use a morph map instead `User::class => 'user'` = search_driver_user (below as well) + 'search_driver_Flarum\Discussion\Discussion' => 'default', + 'search_driver_Flarum\Group\Group' => 'default', + 'search_driver_Flarum\Post\Post' => 'default', + 'search_driver_Flarum\Http\AccessToken' => 'default', ]); }); diff --git a/framework/core/src/User/Search/Filter/EmailFilter.php b/framework/core/src/User/Search/Filter/EmailFilter.php index dbb9867f72..c0e4eb49fe 100644 --- a/framework/core/src/User/Search/Filter/EmailFilter.php +++ b/framework/core/src/User/Search/Filter/EmailFilter.php @@ -9,11 +9,15 @@ namespace Flarum\User\Search\Filter; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; use Illuminate\Database\Query\Builder; +/** + * @implements FilterInterface + */ class EmailFilter implements FilterInterface { use ValidateFilterTrait; diff --git a/framework/core/src/User/Search/Filter/GroupFilter.php b/framework/core/src/User/Search/Filter/GroupFilter.php index 3d9fac9a76..b1de2035c9 100644 --- a/framework/core/src/User/Search/Filter/GroupFilter.php +++ b/framework/core/src/User/Search/Filter/GroupFilter.php @@ -10,12 +10,16 @@ namespace Flarum\User\Search\Filter; use Flarum\Group\Group; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; use Flarum\User\User; use Illuminate\Database\Query\Builder; +/** + * @implements FilterInterface + */ class GroupFilter implements FilterInterface { use ValidateFilterTrait; diff --git a/framework/core/src/User/Search/FulltextFilter.php b/framework/core/src/User/Search/FulltextFilter.php index f26e8e75b3..3f862d7365 100644 --- a/framework/core/src/User/Search/FulltextFilter.php +++ b/framework/core/src/User/Search/FulltextFilter.php @@ -10,11 +10,15 @@ namespace Flarum\User\Search; use Flarum\Search\AbstractFulltextFilter; +use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\SearchState; use Flarum\User\User; use Flarum\User\UserRepository; use Illuminate\Database\Eloquent\Builder; +/** + * @extends AbstractFulltextFilter + */ class FulltextFilter extends AbstractFulltextFilter { public function __construct( @@ -33,12 +37,12 @@ private function getUserSearchSubQuery(string $searchValue): Builder ->where('username', 'like', "$searchValue%"); } - public function search(SearchState $state, string $query): void + public function search(SearchState $state, string $value): void { $state->getQuery() ->whereIn( 'id', - $this->getUserSearchSubQuery($query) + $this->getUserSearchSubQuery($value) ); } } diff --git a/framework/core/src/User/Search/UserSearcher.php b/framework/core/src/User/Search/UserSearcher.php index c758e2f6bd..118e34bd26 100644 --- a/framework/core/src/User/Search/UserSearcher.php +++ b/framework/core/src/User/Search/UserSearcher.php @@ -9,13 +9,13 @@ namespace Flarum\User\Search; -use Flarum\Search\AbstractSearcher; +use Flarum\Search\Database\AbstractSearcher; use Flarum\User\User; use Illuminate\Database\Eloquent\Builder; class UserSearcher extends AbstractSearcher { - protected function getQuery(User $actor): Builder + public function getQuery(User $actor): Builder { return User::whereVisibleTo($actor)->select('users.*'); } diff --git a/framework/core/src/User/User.php b/framework/core/src/User/User.php index a96f63adb1..3d88c69ae1 100644 --- a/framework/core/src/User/User.php +++ b/framework/core/src/User/User.php @@ -55,6 +55,17 @@ * @property \Carbon\Carbon|null $read_notifications_at * @property int $discussion_count * @property int $comment_count + * @property-read Collection $groups + * @property-read Collection $visibleGroups + * @property-read Collection $notifications + * @property-read Collection $accessTokens + * @property-read Collection $posts + * @property-read Collection $discussions + * @property-read Collection $read + * @property-read Collection $unreadNotifications + * @property-read Collection $loginProviders + * @property-read Collection $emailTokens + * @property-read Collection $passwordTokens */ class User extends AbstractModel { diff --git a/framework/core/tests/integration/extenders/SimpleFlarumSearchTest.php b/framework/core/tests/integration/extenders/SearchDriverTest.php similarity index 67% rename from framework/core/tests/integration/extenders/SimpleFlarumSearchTest.php rename to framework/core/tests/integration/extenders/SearchDriverTest.php index 7514a25e1a..8c8d8ea3b3 100644 --- a/framework/core/tests/integration/extenders/SimpleFlarumSearchTest.php +++ b/framework/core/tests/integration/extenders/SearchDriverTest.php @@ -10,21 +10,21 @@ namespace Flarum\Tests\integration\extenders; use Carbon\Carbon; +use Flarum\Discussion\Discussion; use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Extend; -use Flarum\Group\Group; use Flarum\Search\AbstractFulltextFilter; -use Flarum\Search\AbstractSearcher; -use Flarum\Search\FilterInterface; +use Flarum\Search\Database\DatabaseSearchDriver; +use Flarum\Search\Database\DatabaseSearchState; +use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchCriteria; +use Flarum\Search\SearchManager; use Flarum\Search\SearchState; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; use Flarum\User\User; -use Illuminate\Contracts\Container\BindingResolutionException; -use Illuminate\Database\Eloquent\Builder; -class SimpleFlarumSearchTest extends TestCase +class SearchDriverTest extends TestCase { use RetrievesAuthorizedUsers; @@ -70,9 +70,11 @@ public function searchDiscussions($query, $limit = null, array $filters = []) $filters['q'] = $query; - $criteria = new SearchCriteria($actor, $filters); - - return $this->app()->getContainer()->make(DiscussionSearcher::class)->search($criteria, $limit)->getResults(); + return $this->app() + ->getContainer() + ->make(SearchManager::class) + ->query(Discussion::class, new SearchCriteria($actor, $filters, $limit)) + ->getResults(); } /** @@ -96,7 +98,10 @@ public function works_as_expected_with_no_modifications() */ public function custom_full_text_gambit_has_effect_if_added() { - $this->extend((new Extend\SimpleFlarumSearch(DiscussionSearcher::class))->setFullTextFilter(NoResultFullTextFilter::class)); + $this->extend( + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->setFulltext(DiscussionSearcher::class, NoResultFullTextFilter::class) + ); $this->assertEquals('[]', json_encode($this->searchDiscussions('in text', 5))); } @@ -106,7 +111,10 @@ public function custom_full_text_gambit_has_effect_if_added() */ public function custom_filter_has_effect_if_added() { - $this->extend((new Extend\SimpleFlarumSearch(DiscussionSearcher::class))->addFilter(NoResultFilter::class)); + $this->extend( + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addFilter(DiscussionSearcher::class, NoResultFilter::class) + ); $this->prepDb(); @@ -121,9 +129,12 @@ public function custom_filter_has_effect_if_added() */ 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->extend( + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addMutator(DiscussionSearcher::class, function (DatabaseSearchState $search) { + $search->getQuery()->whereRaw('1=0'); + }) + ); $this->prepDb(); @@ -134,53 +145,29 @@ public function search_mutator_has_effect_if_added() * @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))); - } - - /** - * @test - */ - public function cant_resolve_custom_searcher_without_fulltext_gambit() - { - $this->expectException(BindingResolutionException::class); - - $this->app()->getContainer()->make(CustomSearcher::class); - } - - /** - * @test - */ - public function can_resolve_custom_searcher_with_fulltext_gambit() { $this->extend( - (new Extend\SimpleFlarumSearch(CustomSearcher::class))->setFullTextFilter(CustomFullTextFilter::class) + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addMutator(DiscussionSearcher::class, CustomSearchMutator::class) ); - $anExceptionWasThrown = false; - - try { - $this->app()->getContainer()->make(CustomSearcher::class); - } catch (BindingResolutionException) { - $anExceptionWasThrown = true; - } + $this->prepDb(); - $this->assertFalse($anExceptionWasThrown); + $this->assertEquals('[]', json_encode($this->searchDiscussions('in text', 5))); } } class NoResultFullTextFilter extends AbstractFulltextFilter { - public function search(SearchState $state, string $query): void + public function search(SearchState $state, string $value): void { $state->getQuery()->whereRaw('0=1'); } } +/** + * @implements FilterInterface + */ class NoResultFilter implements FilterInterface { public function getFilterKey(): string @@ -205,20 +192,3 @@ public function __invoke($search, $criteria) $search->getQuery()->whereRaw('1=0'); } } - -class CustomSearcher extends AbstractSearcher -{ - // This isn't actually used, we just need it to implement the abstract method. - protected function getQuery(User $actor): Builder - { - return Group::query(); - } -} - -class CustomFullTextFilter extends AbstractFulltextFilter -{ - public function search(SearchState $state, string $query): void - { - // - } -} diff --git a/framework/core/tests/integration/extenders/SearchIndexTest.php b/framework/core/tests/integration/extenders/SearchIndexTest.php new file mode 100644 index 0000000000..0ceccaa471 --- /dev/null +++ b/framework/core/tests/integration/extenders/SearchIndexTest.php @@ -0,0 +1,212 @@ +prepareDatabase([ + 'discussions' => [ + ['id' => 1, 'title' => 'DISCUSSION 1', 'created_at' => Carbon::now()->subDays(1)->toDateTimeString(), 'hidden_at' => null, 'comment_count' => 1, 'user_id' => 1, 'first_post_id' => 1], + ['id' => 2, 'title' => 'DISCUSSION 2', 'created_at' => Carbon::now()->subDays(2)->toDateTimeString(), 'hidden_at' => Carbon::now(), 'comment_count' => 1, 'user_id' => 1], + ], + 'posts' => [ + ['id' => 1, 'number' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

content

', 'hidden_at' => null], + ['id' => 2, 'number' => 2, 'discussion_id' => 1, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

content

', 'hidden_at' => Carbon::now()], + ], + ]); + } + + public static function modelProvider(): array + { + return [ + ['discussions', Discussion::class, 'title'], + ['posts', CommentPost::class, 'content'], + ]; + } + + /** @dataProvider modelProvider */ + public function test_indexer_triggered_on_create(string $type, string $modelClass, string $attribute) + { + $this->extend( + (new Extend\SearchIndex()) + ->indexer($modelClass, TestIndexer::class) + ); + + // Create discussion + $response = $this->send( + $this->request('POST', "/api/$type", [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + $attribute => 'test', + ], + 'relationships' => ($type === 'posts' ? [ + 'discussion' => [ + 'data' => [ + 'type' => 'discussions', + 'id' => 1, + ], + ], + ] : null), + ] + ], + ]), + ); + + Assert::assertEquals('save', TestIndexer::$triggered, $response->getBody()->getContents()); + } + + /** @dataProvider modelProvider */ + public function test_indexer_triggered_on_save(string $type, string $modelClass, string $attribute) + { + $this->extend( + (new Extend\SearchIndex()) + ->indexer($modelClass, TestIndexer::class) + ); + + // Rename discussion + $response = $this->send( + $this->request('PATCH', "/api/$type/1", [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + $attribute => 'changed' + ] + ] + ], + ]), + ); + + Assert::assertEquals('save', TestIndexer::$triggered, $response->getBody()->getContents()); + } + + /** @dataProvider modelProvider */ + public function test_indexer_triggered_on_delete(string $type, string $modelClass, string $attribute) + { + $this->extend( + (new Extend\SearchIndex()) + ->indexer($modelClass, TestIndexer::class) + ); + + // Delete discussion + $response = $this->send( + $this->request('DELETE', "/api/$type/1", [ + 'authenticatedAs' => 1, + 'json' => [], + ]), + ); + + Assert::assertEquals('delete', TestIndexer::$triggered, $response->getBody()->getContents()); + } + + /** @dataProvider modelProvider */ + public function test_indexer_triggered_on_hide(string $type, string $modelClass, string $attribute) + { + $this->extend( + (new Extend\SearchIndex()) + ->indexer($modelClass, TestIndexer::class) + ); + + // Hide discussion + $response = $this->send( + $this->request('PATCH', "/api/$type/1", [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'isHidden' => true + ] + ] + ], + ]), + ); + + Assert::assertEquals('delete', TestIndexer::$triggered, $response->getBody()->getContents()); + } + + /** @dataProvider modelProvider */ + public function test_indexer_triggered_on_restore(string $type, string $modelClass, string $attribute) + { + $this->extend( + (new Extend\SearchIndex()) + ->indexer($modelClass, TestIndexer::class) + ); + + // Restore discussion + $response = $this->send( + $this->request('PATCH', "/api/$type/2", [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'isHidden' => false + ] + ] + ], + ]), + ); + + Assert::assertEquals('save', TestIndexer::$triggered, $response->getBody()->getContents()); + } + + protected function tearDown(): void + { + TestIndexer::$triggered = null; + + parent::tearDown(); + } +} + +class TestIndexer implements IndexerInterface +{ + public static ?string $triggered = null; + + public static function index(): string + { + return 'test'; + } + + public function save(array $models): void + { + self::$triggered = 'save'; + } + + public function delete(array $models): void + { + self::$triggered = 'delete'; + } + + public function build(): void + { + // + } + + public function flush(): void + { + // + } +}