feat: restructure index as parent-child documents with blue-green rebuilds
- Switch to ES parent/child join_field: discussion docs hold all metadata, post docs hold content only. Routing ensures parent and child land on the same shard. Removes the old flat-document approach where post data was duplicated onto every comment document. - Add HasChildQuery with inner_hits so the best-matching post ID is surfaced as mostRelevantPost without a second DB query. - Blue-green index rebuilds: --recreate writes into a timestamped pending index, --swap atomically promotes it via alias. Interrupted builds are resumable with --recreate --continue. --swap requires confirmation before proceeding. - Index hidden posts; non-moderators are filtered at query time via is_hidden on the has_child clause. Core\Hidden and Core\Restored trigger re-indexing so moderators with post.hide can search hidden posts.
This commit is contained in:
parent
80d66c98c4
commit
e2e2d1428f
|
|
@ -12,10 +12,12 @@
|
||||||
|
|
||||||
namespace Blomstra\Search\Api\Controllers;
|
namespace Blomstra\Search\Api\Controllers;
|
||||||
|
|
||||||
|
use Blomstra\Search\Elasticsearch\HasChildQuery;
|
||||||
use Blomstra\Search\Elasticsearch\MatchPhraseQuery;
|
use Blomstra\Search\Elasticsearch\MatchPhraseQuery;
|
||||||
use Blomstra\Search\Elasticsearch\MatchQuery;
|
use Blomstra\Search\Elasticsearch\MatchQuery;
|
||||||
use Blomstra\Search\Elasticsearch\TermsQuery;
|
use Blomstra\Search\Elasticsearch\TermsQuery;
|
||||||
use Blomstra\Search\Save\Document as ElasticDocument;
|
use Blomstra\Search\Searchers\CommentPostSearcher;
|
||||||
|
use Blomstra\Search\Searchers\DiscussionSearcher;
|
||||||
use Blomstra\Search\Searchers\Searcher;
|
use Blomstra\Search\Searchers\Searcher;
|
||||||
use Elasticsearch\Client;
|
use Elasticsearch\Client;
|
||||||
use Flarum\Api\Controller\ListDiscussionsController;
|
use Flarum\Api\Controller\ListDiscussionsController;
|
||||||
|
|
@ -33,9 +35,9 @@ use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
use Spatie\ElasticsearchQueryBuilder\Builder;
|
use Spatie\ElasticsearchQueryBuilder\Builder;
|
||||||
use Spatie\ElasticsearchQueryBuilder\Queries\BoolQuery;
|
use Spatie\ElasticsearchQueryBuilder\Queries\BoolQuery;
|
||||||
use Spatie\ElasticsearchQueryBuilder\Queries\Query;
|
|
||||||
use Spatie\ElasticsearchQueryBuilder\Queries\TermQuery;
|
use Spatie\ElasticsearchQueryBuilder\Queries\TermQuery;
|
||||||
use Spatie\ElasticsearchQueryBuilder\Sorts\Sort;
|
use Spatie\ElasticsearchQueryBuilder\Sorts\Sort;
|
||||||
use Tobscure\JsonApi\Document;
|
use Tobscure\JsonApi\Document;
|
||||||
|
|
@ -51,117 +53,100 @@ class SearchController extends ListDiscussionsController
|
||||||
'view_count' => 'view_count',
|
'view_count' => 'view_count',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected Collection $searchers;
|
|
||||||
protected bool $matchSentences;
|
protected bool $matchSentences;
|
||||||
protected bool $matchWords;
|
protected bool $matchWords;
|
||||||
|
protected ?Searcher $discussionSearcher;
|
||||||
|
protected ?Searcher $postSearcher;
|
||||||
|
|
||||||
public function __construct(protected Client $elastic, protected UrlGenerator $uri, Container $container, SettingsRepositoryInterface $settings)
|
public function __construct(protected Client $elastic, protected UrlGenerator $uri, Container $container, SettingsRepositoryInterface $settings)
|
||||||
{
|
{
|
||||||
$this->searchers = $this->gatherSearchers($container->tagged('blomstra.search.searchers'));
|
|
||||||
|
|
||||||
$this->matchSentences = true;
|
$this->matchSentences = true;
|
||||||
$this->matchWords = true;
|
$this->matchWords = true;
|
||||||
}
|
|
||||||
|
|
||||||
protected function gatherSearchers(iterable $searchers)
|
$searchers = collect($container->tagged('blomstra.search.searchers'));
|
||||||
{
|
|
||||||
return collect($searchers)
|
$this->discussionSearcher = $searchers->first(fn ($s) => $s instanceof DiscussionSearcher);
|
||||||
->map(fn ($searcher) => new $searcher())
|
$this->postSearcher = $searchers->first(fn ($s) => $s instanceof CommentPostSearcher);
|
||||||
->filter(fn (Searcher $searcher) => $searcher->enabled());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function data(ServerRequestInterface $request, Document $document)
|
protected function data(ServerRequestInterface $request, Document $document)
|
||||||
{
|
{
|
||||||
// Not used for now.
|
|
||||||
$type = Arr::get($request->getQueryParams(), 'type');
|
|
||||||
|
|
||||||
$actor = RequestUtil::getActor($request);
|
$actor = RequestUtil::getActor($request);
|
||||||
|
|
||||||
$filters = $this->extractFilter($request);
|
$filters = $this->extractFilter($request);
|
||||||
|
|
||||||
$search = $this->getSearch($filters);
|
$search = $this->getSearch($filters);
|
||||||
|
|
||||||
$limit = $this->extractLimit($request);
|
$limit = $this->extractLimit($request);
|
||||||
$offset = $this->extractOffset($request);
|
$offset = $this->extractOffset($request);
|
||||||
|
|
||||||
$include = array_merge($this->extractInclude($request), ['state']);
|
$include = array_merge($this->extractInclude($request), ['state']);
|
||||||
|
|
||||||
$filterQuery = BoolQuery::create();
|
$query = BoolQuery::create()
|
||||||
|
// Always restrict to discussion documents; posts are only searched via has_child.
|
||||||
|
->add(TermQuery::create('join_field', 'discussion'), 'filter');
|
||||||
|
|
||||||
if (!empty($search)) {
|
if (!empty($search)) {
|
||||||
if ($this->matchSentences) {
|
$query->add($this->buildTextQuery($search, $actor));
|
||||||
$filterQuery->add($this->sentenceMatch($search));
|
|
||||||
}
|
|
||||||
if ($this->matchWords) {
|
|
||||||
$filterQuery->add($this->wordMatch($search, 'and'));
|
|
||||||
}
|
|
||||||
if ($this->matchWords) {
|
|
||||||
$filterQuery->add($this->wordMatch($search, 'or'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->addFilters($query, $actor, $filters);
|
||||||
|
|
||||||
$builder = (new Builder($this->elastic))
|
$builder = (new Builder($this->elastic))
|
||||||
->index(resolve('blomstra.search.elastic_index'))
|
->index(resolve('blomstra.search.elastic_index'))
|
||||||
->size($limit + 1)
|
->size($limit + 1)
|
||||||
->from($this->extractOffset($request))
|
->from($offset)
|
||||||
->addQuery(
|
->addQuery($query);
|
||||||
$this->addFilters($filterQuery, $actor, $filters)
|
|
||||||
);
|
|
||||||
|
|
||||||
$knownSortFields = array_merge(array_values($this->translateSort), ['rawId']);
|
$knownSortFields = array_merge(array_values($this->translateSort), ['rawId']);
|
||||||
|
$logger = resolve(LoggerInterface::class);
|
||||||
|
|
||||||
|
$phpSortField = null;
|
||||||
|
$phpSortDir = 'desc';
|
||||||
|
|
||||||
foreach ($this->extractSort($request) as $field => $direction) {
|
foreach ($this->extractSort($request) as $field => $direction) {
|
||||||
$translated = $this->translateSort[$field] ?? $field;
|
$translated = $this->translateSort[$field] ?? $field;
|
||||||
|
|
||||||
if (!in_array($translated, $knownSortFields)) {
|
if (!in_array($translated, $knownSortFields)) {
|
||||||
resolve(\Psr\Log\LoggerInterface::class)->warning(
|
$logger->warning("blomstra/search: unknown sort field \"{$field}\", ignoring.");
|
||||||
"blomstra/search: unknown sort field \"{$field}\", ignoring."
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$builder->addSort(new Sort($translated, $direction));
|
$builder->addSort(new Sort($translated, $direction));
|
||||||
|
|
||||||
|
if ($phpSortField === null && $translated !== 'rawId') {
|
||||||
|
$phpSortField = $translated;
|
||||||
|
$phpSortDir = $direction;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = $builder->search();
|
$response = $builder->search();
|
||||||
|
|
||||||
Discussion::setStateUser($actor);
|
Discussion::setStateUser($actor);
|
||||||
|
|
||||||
// Eager load groups for use in the policies (isAdmin check)
|
|
||||||
if (in_array('mostRelevantPost.user', $include)) {
|
if (in_array('mostRelevantPost.user', $include)) {
|
||||||
$include[] = 'mostRelevantPost.user.groups';
|
$include[] = 'mostRelevantPost.user.groups';
|
||||||
|
|
||||||
// If the first level of the relationship wasn't explicitly included,
|
|
||||||
// add it so the code below can look for it
|
|
||||||
if (!in_array('mostRelevantPost', $include)) {
|
if (!in_array('mostRelevantPost', $include)) {
|
||||||
$include[] = 'mostRelevantPost';
|
$include[] = 'mostRelevantPost';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// we need to retrieve all discussion ids and when the results are posts,
|
// All hits are discussion documents. Extract the best-matching post ID from
|
||||||
// their ids as most relevant post id
|
// inner_hits when a has_child clause matched (i.e. the match came from a post).
|
||||||
$results = Collection::make(Arr::get($response, 'hits.hits'))
|
$results = Collection::make(Arr::get($response, 'hits.hits'))
|
||||||
->map(function ($hit) {
|
->map(function ($hit) {
|
||||||
$type = $hit['_source']['type'];
|
// _id is "discussions:123" — parse the numeric part directly.
|
||||||
$id = Str::after($hit['_source']['id'], "$type:");
|
$discussionId = Str::after($hit['_id'], 'discussions:');
|
||||||
|
|
||||||
|
// rawId on the inner hit gives us the integer post ID.
|
||||||
|
$bestPostId = Arr::get($hit, 'inner_hits.best_post.hits.hits.0._source.rawId');
|
||||||
|
|
||||||
if ($type === 'posts') {
|
|
||||||
return [
|
return [
|
||||||
'most_relevant_post_id' => $id,
|
'discussion_id' => $discussionId,
|
||||||
'weight' => Arr::get($hit, 'sort.0'),
|
'most_relevant_post_id' => $bestPostId,
|
||||||
|
'weight' => Arr::get($hit, 'sort.0', Arr::get($hit, '_score', 0)),
|
||||||
];
|
];
|
||||||
} else {
|
|
||||||
return [
|
|
||||||
'discussion_id' => $id,
|
|
||||||
'weight' => Arr::get($hit, 'sort.0'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$document->addPaginationLinks(
|
$document->addPaginationLinks(
|
||||||
$this->uri->to('api')->route('blomstra.search', [
|
$this->uri->to('api')->route('blomstra.search', ['type' => 'discussions']),
|
||||||
'type' => 'discussions',
|
|
||||||
]),
|
|
||||||
$request->getQueryParams(),
|
$request->getQueryParams(),
|
||||||
$offset,
|
$offset,
|
||||||
$limit,
|
$limit,
|
||||||
|
|
@ -171,28 +156,25 @@ class SearchController extends ListDiscussionsController
|
||||||
$results = $results->take($limit);
|
$results = $results->take($limit);
|
||||||
|
|
||||||
$discussions = Discussion::query()
|
$discussions = Discussion::query()
|
||||||
->select('discussions.*')
|
->when(
|
||||||
->join('posts', 'posts.discussion_id', 'discussions.id')
|
$actor->isGuest() || !$actor->hasPermission('discussion.hide'),
|
||||||
// Extra safety to prevent leaking hidden discussion (titles) towards search results.
|
fn ($q) => $q->whereNull('hidden_at')
|
||||||
->when($actor->isGuest() || !$actor->hasPermission('discussion.hide'), fn ($query) => $query->whereNull('discussions.hidden_at'))
|
)
|
||||||
->where(function ($query) use ($results) {
|
->whereIn('id', $results->pluck('discussion_id')->filter())
|
||||||
$query
|
|
||||||
->whereIn('discussions.id', $results->pluck('discussion_id')->filter())
|
|
||||||
->orWhereIn('posts.id', $results->pluck('most_relevant_post_id')->filter());
|
|
||||||
})
|
|
||||||
->get()
|
->get()
|
||||||
->each(function (Discussion $discussion) use ($results) {
|
->each(function (Discussion $discussion) use ($results) {
|
||||||
if (in_array($discussion->id, $results->pluck('discussion_id')->toArray())) {
|
$result = $results->firstWhere('discussion_id', $discussion->id);
|
||||||
$discussion->most_relevant_post_id = $discussion->first_post_id;
|
|
||||||
$discussion->weight = $results->firstWhere('discussion_id', $discussion->id)['weight'] ?? 0;
|
$discussion->most_relevant_post_id = $result['most_relevant_post_id']
|
||||||
} else {
|
?? $discussion->first_post_id;
|
||||||
$post = $discussion->posts()->whereIn('id', $results->pluck('most_relevant_post_id'))->first();
|
$discussion->weight = $result['weight'] ?? 0;
|
||||||
$discussion->most_relevant_post_id = $post?->id ?? $discussion->first_post_id;
|
|
||||||
$discussion->weight = $results->firstWhere('most_relevant_post_id', $post?->id)['weight'] ?? 0;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
->keyBy('id')
|
->keyBy('id')
|
||||||
->sortByDesc('weight')
|
->when(
|
||||||
|
$phpSortField,
|
||||||
|
fn ($c) => $phpSortDir === 'desc' ? $c->sortByDesc($phpSortField) : $c->sortBy($phpSortField),
|
||||||
|
fn ($c) => $c->sortByDesc('weight')
|
||||||
|
)
|
||||||
->unique();
|
->unique();
|
||||||
|
|
||||||
$this->loadRelations($discussions, $include);
|
$this->loadRelations($discussions, $include);
|
||||||
|
|
@ -210,27 +192,63 @@ class SearchController extends ListDiscussionsController
|
||||||
return $discussions;
|
return $discussions;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getDocument(string $type): ?ElasticDocument
|
/**
|
||||||
|
* Build the text-matching portion of the query.
|
||||||
|
*
|
||||||
|
* Discussion titles are matched directly (filtered to join_field=discussion).
|
||||||
|
* Post bodies are matched via has_child with score_mode=sum so discussions
|
||||||
|
* with many matching posts score higher than those with a single strong match.
|
||||||
|
* inner_hits returns the best-scoring post for use as mostRelevantPost.
|
||||||
|
* Hidden posts are only included in matching for users with post.hide permission.
|
||||||
|
*/
|
||||||
|
protected function buildTextQuery(string $search, User $actor): BoolQuery
|
||||||
{
|
{
|
||||||
$documents = resolve(Container::class)->tagged('blomstra.search.documents');
|
$textQuery = BoolQuery::create();
|
||||||
|
|
||||||
return collect($documents)->first(function (ElasticDocument $document) use ($type) {
|
if ($this->discussionSearcher?->enabled()) {
|
||||||
return $document->type() === $type;
|
$textQuery->add($this->buildShouldClauses($search, $this->discussionSearcher->boost()), 'should');
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if ($this->postSearcher?->enabled()) {
|
||||||
|
$postQuery = $this->buildShouldClauses($search, $this->postSearcher->boost());
|
||||||
|
|
||||||
|
// Guests and non-moderators may not see hidden posts; exclude them from child matching.
|
||||||
|
if ($actor->isGuest() || !$actor->hasPermission('post.hide')) {
|
||||||
|
$postQuery->add(TermQuery::create('is_hidden', false), 'filter');
|
||||||
|
}
|
||||||
|
|
||||||
|
$textQuery->add(
|
||||||
|
HasChildQuery::create('post', $postQuery)->withInnerHits(),
|
||||||
|
'should'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $textQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildShouldClauses(string $search, float $boost): BoolQuery
|
||||||
|
{
|
||||||
|
$should = BoolQuery::create();
|
||||||
|
|
||||||
|
if ($this->matchSentences) {
|
||||||
|
$should->add((new MatchPhraseQuery('content', $search))->boost(2 * $boost), 'should');
|
||||||
|
}
|
||||||
|
if ($this->matchWords) {
|
||||||
|
$should->add((new MatchQuery('content', $search))->operator('and')->boost(1.8 * $boost), 'should');
|
||||||
|
$should->add((new MatchQuery('content', $search))->operator('or')->boost(0.8 * $boost), 'should');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $should;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function extensionEnabled(string $extension): bool
|
protected function extensionEnabled(string $extension): bool
|
||||||
{
|
{
|
||||||
/** @var ExtensionManager $manager */
|
return resolve(ExtensionManager::class)->isEnabled($extension);
|
||||||
$manager = resolve(ExtensionManager::class);
|
|
||||||
|
|
||||||
return $manager->isEnabled($extension);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function addFilters(BoolQuery $query, User $actor, array $filters = []): BoolQuery
|
protected function addFilters(BoolQuery $query, User $actor, array $filters = []): void
|
||||||
{
|
{
|
||||||
$groups = $this->getGroups($actor);
|
$groups = $this->getGroups($actor);
|
||||||
|
|
||||||
$onlyPrivate = Str::contains($filters['q'] ?? '', 'is:private');
|
$onlyPrivate = Str::contains($filters['q'] ?? '', 'is:private');
|
||||||
|
|
||||||
$subQuery = BoolQuery::create()
|
$subQuery = BoolQuery::create()
|
||||||
|
|
@ -263,55 +281,12 @@ class SearchController extends ListDiscussionsController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->add(
|
$query->add($subQuery, 'filter');
|
||||||
$subQuery,
|
|
||||||
'filter'
|
|
||||||
);
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function boolQuery(Query $parent, float $boost = 1): Query
|
|
||||||
{
|
|
||||||
$bool = new BoolQuery();
|
|
||||||
|
|
||||||
/** @var Searcher $searcher */
|
|
||||||
foreach ($this->searchers as $searcher) {
|
|
||||||
$searcher = new $searcher();
|
|
||||||
|
|
||||||
$bool->add(
|
|
||||||
BoolQuery::create()
|
|
||||||
->add(TermQuery::create('type', $searcher->type()), 'filter')
|
|
||||||
->add(clone $parent->boost($boost * $searcher->boost())),
|
|
||||||
'should'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $bool;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function sentenceMatch(string $q): Query
|
|
||||||
{
|
|
||||||
$query = (new MatchPhraseQuery('content', $q));
|
|
||||||
|
|
||||||
return $this->boolQuery($query, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function wordMatch(string $q, string $operator = 'or'): Query
|
|
||||||
{
|
|
||||||
$query = (new MatchQuery('content', $q))
|
|
||||||
->operator($operator);
|
|
||||||
|
|
||||||
$boost = $operator === 'and' ? 1.8 : .8;
|
|
||||||
|
|
||||||
return $this->boolQuery($query, $boost);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getGroups(User $actor): Collection
|
protected function getGroups(User $actor): Collection
|
||||||
{
|
{
|
||||||
/** @var Collection $groups */
|
|
||||||
$groups = $actor->groups->pluck('id');
|
$groups = $actor->groups->pluck('id');
|
||||||
|
|
||||||
$groups->add(Group::GUEST_ID);
|
$groups->add(Group::GUEST_ID);
|
||||||
|
|
||||||
if ($actor->is_email_confirmed) {
|
if ($actor->is_email_confirmed) {
|
||||||
|
|
@ -327,9 +302,7 @@ class SearchController extends ListDiscussionsController
|
||||||
|
|
||||||
if ($search) {
|
if ($search) {
|
||||||
$q = collect(explode(' ', $search))
|
$q = collect(explode(' ', $search))
|
||||||
->filter(function (string $part) {
|
->filter(fn (string $part) => $part !== 'is:private')
|
||||||
return $part !== 'is:private';
|
|
||||||
})
|
|
||||||
->filter()
|
->filter()
|
||||||
->join(' ');
|
->join(' ');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,33 +33,290 @@ class BuildCommand extends Command
|
||||||
{--max-id= : Limits for each object the number of items to seed}
|
{--max-id= : Limits for each object the number of items to seed}
|
||||||
{--throttle= : Number of seconds to wait between pushing to the queue}
|
{--throttle= : Number of seconds to wait between pushing to the queue}
|
||||||
{--only= : type to run seeder for, eg discussions or posts}
|
{--only= : type to run seeder for, eg discussions or posts}
|
||||||
{--recreate : create or recreate the index}
|
{--recreate : Build into a new timestamped pending index (never touches the live alias)}
|
||||||
{--mapping : recreate the mapping}
|
{--discard : With --recreate, discard any in-progress pending build and start completely fresh}
|
||||||
{--continue : continue each object type where you left off}
|
{--build-only : Deprecated no-op; --recreate no longer auto-swaps (kept for scripting compatibility)}
|
||||||
{--seed-missing : attempt to seed only objects that are missing in the index}';
|
{--mapping : Push updated mapping to the active index without rebuilding}
|
||||||
|
{--continue : Resume each seeder from where it left off (use with --recreate when resuming an interrupted build)}
|
||||||
|
{--seed-missing : Seed only documents missing from the active index}
|
||||||
|
{--swap : Atomically swap the alias from the active index to the completed pending index}
|
||||||
|
{--i-am-sure : Required with --swap; confirms the queue is fully drained before promoting}';
|
||||||
|
|
||||||
protected $description = 'Rebuilds the complete search server with its documents.';
|
protected $description = 'Rebuilds the complete search index.';
|
||||||
|
|
||||||
public function handle(Container $container)
|
public function handle(Container $container): void
|
||||||
{
|
{
|
||||||
/** @var string $index */
|
|
||||||
$index = $container->make('blomstra.search.elastic_index');
|
|
||||||
|
|
||||||
/** @var array|string[] $seeders */
|
|
||||||
$seeders = $container->tagged('blomstra.search.seeders');
|
|
||||||
|
|
||||||
/** @var Queue $queue */
|
|
||||||
$queue = $container->make(Queue::class);
|
|
||||||
|
|
||||||
/** @var Client $client */
|
/** @var Client $client */
|
||||||
$client = $container->make(Client::class);
|
$client = $container->make(Client::class);
|
||||||
|
|
||||||
/** @var SettingsRepositoryInterface $settings */
|
/** @var SettingsRepositoryInterface $settings */
|
||||||
$settings = $container->make(SettingsRepositoryInterface::class);
|
$settings = $container->make(SettingsRepositoryInterface::class);
|
||||||
|
|
||||||
// Elastic scheme definition.
|
/** @var string $alias */
|
||||||
$properties = [
|
$alias = $container->make('blomstra.search.elastic_index');
|
||||||
|
|
||||||
|
// --swap: promote the pending index to live, nothing else.
|
||||||
|
if ($this->option('swap')) {
|
||||||
|
if (!$this->option('i-am-sure')) {
|
||||||
|
$this->warn('--swap promotes the pending index to live. This cannot be undone.');
|
||||||
|
$this->line('');
|
||||||
|
$this->line('Before swapping, ensure the queue is fully drained:');
|
||||||
|
$this->line(' php flarum queue:work --stop-when-empty');
|
||||||
|
$this->line('');
|
||||||
|
|
||||||
|
if (!$this->confirm('Has the queue been drained? Proceed with the swap?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->performSwap($client, $alias, $settings);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Queue $queue */
|
||||||
|
$queue = $container->make(Queue::class);
|
||||||
|
|
||||||
|
/** @var Seeder[] $seeders */
|
||||||
|
$seeders = collect($container->tagged('blomstra.search.seeders'));
|
||||||
|
|
||||||
|
// Determine which concrete index to write into.
|
||||||
|
if ($this->option('recreate')) {
|
||||||
|
// If a pending build exists, require an explicit choice before touching anything.
|
||||||
|
$pending = $settings->get('blomstra-search.pending-index');
|
||||||
|
if ($pending && $client->indices()->exists(['index' => $pending])
|
||||||
|
&& !$this->option('continue') && !$this->option('discard')
|
||||||
|
) {
|
||||||
|
$this->warn("An in-progress build already exists: $pending");
|
||||||
|
$this->line('');
|
||||||
|
$this->line('Choose one of:');
|
||||||
|
$this->line(' --recreate --continue Resume each seeder from where it left off');
|
||||||
|
$this->line(' --recreate --discard Discard this build and start completely fresh');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetIndex = $this->preparePendingIndex($client, $alias, $settings, $seeders);
|
||||||
|
} else {
|
||||||
|
// --mapping / --seed-missing / normal: write directly through the alias.
|
||||||
|
$targetIndex = $alias;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply or update the mapping.
|
||||||
|
if ($this->option('recreate') || $this->option('mapping')) {
|
||||||
|
$client->indices()->putMapping([
|
||||||
|
'index' => $targetIndex,
|
||||||
|
'body' => $this->mappingProperties(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run seeders into $targetIndex.
|
||||||
|
$only = $this->option('only');
|
||||||
|
|
||||||
|
/** @var Seeder $seeder */
|
||||||
|
foreach ($seeders as $seeder) {
|
||||||
|
if ($only && $seeder->type() !== $only) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
|
||||||
|
$continueAt = $this->option('continue')
|
||||||
|
? ($this->getContinueAt($settings, $seeder->type()) ?? $seeder->query()->max('id'))
|
||||||
|
: $seeder->query()->max('id');
|
||||||
|
|
||||||
|
$seeded = null;
|
||||||
|
|
||||||
|
while ($continueAt !== null) {
|
||||||
|
$rangeFrom = max(1, $continueAt - 1000);
|
||||||
|
$rangeTo = $continueAt;
|
||||||
|
|
||||||
|
if ($this->option('seed-missing')) {
|
||||||
|
$response = (new Builder($client))
|
||||||
|
->index($targetIndex)
|
||||||
|
->size(1000)
|
||||||
|
->addQuery(
|
||||||
|
(new BoolQuery())
|
||||||
|
->add((new RangeQuery('rawId'))->gte($rangeFrom)->lte($rangeTo))
|
||||||
|
->add(TermQuery::create('join_field', $seeder->joinRelation()))
|
||||||
|
)
|
||||||
|
->search();
|
||||||
|
|
||||||
|
$seeded = Arr::pluck(Arr::get($response, 'hits.hits'), '_source.rawId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Collection $collection */
|
||||||
|
$collection = $seeder->query()
|
||||||
|
->latest('id')
|
||||||
|
->whereBetween('id', [$rangeFrom, $rangeTo])
|
||||||
|
->when($this->option('max-id'), fn ($q, $id) => $q->where('id', '<=', $id))
|
||||||
|
->when($seeded, fn ($q, $seeded) => $q->whereNotIn('id', $seeded))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$min = $collection->min('id');
|
||||||
|
|
||||||
|
if ($this->option('seed-missing') && $collection->isEmpty()) {
|
||||||
|
$continueAt = $rangeFrom > 2 ? $rangeFrom - 1 : null;
|
||||||
|
} else {
|
||||||
|
$continueAt = $min && $min > 2 ? $min - 1 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($collection->isNotEmpty()) {
|
||||||
|
$queue->pushOn(Job::$onQueue, new UpdateSearchJob($collection, $seeder, $targetIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("IDs {$rangeFrom}–{$rangeTo} | type: {$seeder->type()} | queued: {$collection->count()}.");
|
||||||
|
|
||||||
|
$total += $collection->count();
|
||||||
|
$this->setContinueAt($settings, $seeder->type(), $continueAt);
|
||||||
|
|
||||||
|
if ($throttle = $this->option('throttle')) {
|
||||||
|
$this->info("Throttling for $throttle seconds");
|
||||||
|
sleep($throttle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Queued a total of $total {$seeder->type()} for indexing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('recreate')) {
|
||||||
|
$this->info("Build complete. Drain the queue, then promote '$targetIndex' to live:");
|
||||||
|
$this->line(" php flarum queue:work --stop-when-empty");
|
||||||
|
$this->line(" php flarum blomstra:search:index --swap");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare the concrete index that the current build will write into.
|
||||||
|
*
|
||||||
|
* - If a pending build exists and --discard is not set, resume it.
|
||||||
|
* - Otherwise create a fresh timestamped index and save it as pending.
|
||||||
|
*/
|
||||||
|
protected function preparePendingIndex(
|
||||||
|
Client $client,
|
||||||
|
string $alias,
|
||||||
|
SettingsRepositoryInterface $settings,
|
||||||
|
iterable $seeders
|
||||||
|
): string {
|
||||||
|
$pending = $settings->get('blomstra-search.pending-index');
|
||||||
|
|
||||||
|
// --discard: throw away any in-progress build.
|
||||||
|
if ($this->option('discard') && $pending) {
|
||||||
|
if ($client->indices()->exists(['index' => $pending])) {
|
||||||
|
$client->indices()->delete(['index' => $pending]);
|
||||||
|
$this->info("Discarded pending index: $pending");
|
||||||
|
}
|
||||||
|
$pending = null;
|
||||||
|
$settings->set('blomstra-search.pending-index', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume an existing pending build (only reached when --continue or --discard was specified).
|
||||||
|
if ($pending && $client->indices()->exists(['index' => $pending])) {
|
||||||
|
$this->info("Resuming pending index build: $pending");
|
||||||
|
return $pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh build: create a new timestamped concrete index.
|
||||||
|
$pending = $alias . '_' . date('YmdHis');
|
||||||
|
|
||||||
|
$client->indices()->create([
|
||||||
|
'index' => $pending,
|
||||||
|
'body' => ['settings' => $this->indexSettings($settings)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$settings->set('blomstra-search.pending-index', $pending);
|
||||||
|
|
||||||
|
// Clear per-seeder progress so the fresh build starts from the top.
|
||||||
|
foreach ($seeders as $seeder) {
|
||||||
|
$this->setContinueAt($settings, $seeder->type(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Created pending index: $pending");
|
||||||
|
|
||||||
|
return $pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically promote the pending index to live by swapping the alias.
|
||||||
|
*
|
||||||
|
* Handles both the normal alias-swap case and the one-time migration from
|
||||||
|
* an older installation where the configured name was a concrete index.
|
||||||
|
*/
|
||||||
|
protected function performSwap(Client $client, string $alias, SettingsRepositoryInterface $settings): void
|
||||||
|
{
|
||||||
|
$pending = $settings->get('blomstra-search.pending-index');
|
||||||
|
|
||||||
|
if (!$pending || !$client->indices()->exists(['index' => $pending])) {
|
||||||
|
$this->error('No pending index found. Run --recreate [--build-only] first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$aliasExists = (bool) $client->indices()->existsAlias(['name' => $alias]);
|
||||||
|
$indexExists = !$aliasExists && (bool) $client->indices()->exists(['index' => $alias]);
|
||||||
|
|
||||||
|
if ($aliasExists) {
|
||||||
|
// Normal case: atomic remove-old / add-new in a single API call.
|
||||||
|
$result = $client->indices()->getAlias(['name' => $alias]);
|
||||||
|
$oldIndex = array_key_first($result);
|
||||||
|
|
||||||
|
$client->indices()->updateAliases([
|
||||||
|
'body' => ['actions' => [
|
||||||
|
['remove' => ['index' => $oldIndex, 'alias' => $alias]],
|
||||||
|
['add' => ['index' => $pending, 'alias' => $alias]],
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->info("Alias '$alias' → '$pending'. Swap complete.");
|
||||||
|
$client->indices()->delete(['index' => $oldIndex, 'ignore_unavailable' => true]);
|
||||||
|
$this->info("Deleted old index: $oldIndex");
|
||||||
|
} else {
|
||||||
|
// One-time migration: the configured name was a concrete index (old install).
|
||||||
|
// Brief downtime window here is acceptable — it only happens once.
|
||||||
|
if ($indexExists) {
|
||||||
|
$client->indices()->delete(['index' => $alias]);
|
||||||
|
$this->info("Deleted legacy concrete index: $alias");
|
||||||
|
}
|
||||||
|
|
||||||
|
$client->indices()->putAlias(['index' => $pending, 'name' => $alias]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings->set('blomstra-search.active-index', $pending);
|
||||||
|
$settings->set('blomstra-search.pending-index', null);
|
||||||
|
|
||||||
|
$this->info("Alias '$alias' → '$pending'. Swap complete.");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function indexSettings(SettingsRepositoryInterface $settings): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index.max_ngram_diff' => 7,
|
||||||
|
'analysis' => [
|
||||||
|
'analyzer' => [
|
||||||
|
'flarum_analyzer' => [
|
||||||
|
'type' => $settings->get('blomstra-search.analyzer-language') ?: 'english',
|
||||||
|
],
|
||||||
|
'flarum_analyzer_partial' => [
|
||||||
|
'type' => 'custom',
|
||||||
|
'tokenizer' => 'standard',
|
||||||
|
'filter' => ['lowercase', 'partial_search_filter'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'filter' => [
|
||||||
|
'partial_search_filter' => [
|
||||||
|
'type' => 'ngram',
|
||||||
|
'min_gram' => 3,
|
||||||
|
'max_gram' => 10,
|
||||||
|
'token_chars' => ['letter', 'digit', 'symbol'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mappingProperties(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
'properties' => [
|
'properties' => [
|
||||||
|
'join_field' => ['type' => 'join', 'relations' => ['discussion' => 'post']],
|
||||||
|
'discussion_id' => ['type' => 'integer'],
|
||||||
'content' => ['type' => 'text', 'analyzer' => 'flarum_analyzer_partial', 'search_analyzer' => 'flarum_analyzer'],
|
'content' => ['type' => 'text', 'analyzer' => 'flarum_analyzer_partial', 'search_analyzer' => 'flarum_analyzer'],
|
||||||
'rawId' => ['type' => 'integer'],
|
'rawId' => ['type' => 'integer'],
|
||||||
'created_at' => ['type' => 'date'],
|
'created_at' => ['type' => 'date'],
|
||||||
|
|
@ -72,149 +329,20 @@ class BuildCommand extends Command
|
||||||
'recipient_users' => ['type' => 'integer'],
|
'recipient_users' => ['type' => 'integer'],
|
||||||
'comment_count' => ['type' => 'integer'],
|
'comment_count' => ['type' => 'integer'],
|
||||||
'view_count' => ['type' => 'integer'],
|
'view_count' => ['type' => 'integer'],
|
||||||
|
'is_hidden' => ['type' => 'boolean'],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
// Remove/delete the whole index.
|
|
||||||
if ($this->option('recreate')) {
|
|
||||||
// Flush the index.
|
|
||||||
$client->indices()->delete([
|
|
||||||
'index' => $index,
|
|
||||||
'ignore_unavailable' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create a new index.
|
|
||||||
$client->indices()->create([
|
|
||||||
'index' => $index,
|
|
||||||
'body' => [
|
|
||||||
'settings' => [
|
|
||||||
'index.max_ngram_diff' => 7,
|
|
||||||
'analysis' => [
|
|
||||||
'analyzer' => [
|
|
||||||
'flarum_analyzer' => [
|
|
||||||
'type' => $settings->get('blomstra-search.analyzer-language') ?: 'english',
|
|
||||||
],
|
|
||||||
'flarum_analyzer_partial' => [
|
|
||||||
'type' => 'custom',
|
|
||||||
'tokenizer' => 'standard',
|
|
||||||
'filter' => [
|
|
||||||
'lowercase',
|
|
||||||
'partial_search_filter',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'filter' => [
|
|
||||||
'partial_search_filter' => [
|
|
||||||
'type' => 'ngram',
|
|
||||||
'min_gram' => 3,
|
|
||||||
'max_gram' => 10,
|
|
||||||
'token_chars' => ['letter', 'digit', 'symbol'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the index.
|
protected function getContinueAt(SettingsRepositoryInterface $settings, string $type): ?int
|
||||||
if ($this->option('recreate') || $this->option('mapping')) {
|
|
||||||
$client->indices()->putMapping([
|
|
||||||
'index' => $index,
|
|
||||||
'body' => $properties,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed only a specific resource.
|
|
||||||
$only = $this->option('only');
|
|
||||||
|
|
||||||
/** @var Seeder $seeder */
|
|
||||||
foreach ($seeders as $seeder) {
|
|
||||||
if ($only && $seeder->type() !== $only) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$total = 0;
|
|
||||||
|
|
||||||
$continueAt = $this->option('continue')
|
|
||||||
? ($this->continueAt($seeder->type()) ?? $seeder->query()->max('id'))
|
|
||||||
: $seeder->query()->max('id');
|
|
||||||
|
|
||||||
$seeded = null;
|
|
||||||
|
|
||||||
while ($continueAt !== null) {
|
|
||||||
$rangeFrom = max(1, $continueAt - 1000);
|
|
||||||
$rangeTo = $continueAt;
|
|
||||||
|
|
||||||
if ($this->option('seed-missing')) {
|
|
||||||
$response = (new Builder($client))
|
|
||||||
->index($index)
|
|
||||||
->size(1000)
|
|
||||||
->addQuery(
|
|
||||||
(new BoolQuery())
|
|
||||||
->add((new RangeQuery('rawId'))
|
|
||||||
->gte($rangeFrom)
|
|
||||||
->lte($rangeTo))
|
|
||||||
->add(TermQuery::create('type', $seeder->type()))
|
|
||||||
)
|
|
||||||
->search();
|
|
||||||
|
|
||||||
$seeded = Arr::pluck(Arr::get($response, 'hits.hits'), '_source.rawId');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var Collection $collection */
|
|
||||||
$collection = $seeder->query()
|
|
||||||
->latest('id')
|
|
||||||
->whereBetween('id', [$rangeFrom, $rangeTo])
|
|
||||||
->when($this->option('max-id'), function ($query, $id) {
|
|
||||||
$query->where('id', '<=', $id);
|
|
||||||
})
|
|
||||||
->when($seeded, fn ($query, $seeded) => $query->whereNotIn('id', $seeded))
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$min = $collection->min('id');
|
|
||||||
|
|
||||||
if ($this->option('seed-missing') && $collection->isEmpty()) {
|
|
||||||
// All docs in this range are already indexed; advance past the range bottom.
|
|
||||||
$continueAt = $rangeFrom > 2 ? $rangeFrom - 1 : null;
|
|
||||||
} else {
|
|
||||||
$continueAt = $min && $min > 2 ? $min - 1 : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($collection->isNotEmpty()) {
|
|
||||||
$queue->pushOn(Job::$onQueue, new UpdateSearchJob($collection, $seeder));
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info("IDs {$rangeFrom}–{$rangeTo} | type: {$seeder->type()} | queued: {$collection->count()}.");
|
|
||||||
|
|
||||||
$total += $collection->count();
|
|
||||||
|
|
||||||
$this->continueAt(
|
|
||||||
$seeder->type(),
|
|
||||||
$continueAt
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($throttle = $this->option('throttle')) {
|
|
||||||
$this->info("Throttling for $throttle seconds");
|
|
||||||
sleep($throttle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info("Queued a total of $total {$seeder->type()} for indexing.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function continueAt(string $type, int $at = null)
|
|
||||||
{
|
{
|
||||||
/** @var SettingsRepositoryInterface $settings */
|
$raw = $settings->get("blomstra-search.continued-at.$type");
|
||||||
$settings = resolve(SettingsRepositoryInterface::class);
|
|
||||||
|
|
||||||
$key = "blomstra-search.continued-at.$type";
|
return $raw !== null ? (int) $raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
if ($at) {
|
protected function setContinueAt(SettingsRepositoryInterface $settings, string $type, ?int $at): void
|
||||||
$settings->set($key, $at);
|
{
|
||||||
} else {
|
$settings->set("blomstra-search.continued-at.$type", $at);
|
||||||
return $settings->get($key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of blomstra/search.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2022 Blomstra Ltd.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE.md
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Blomstra\Search\Elasticsearch;
|
||||||
|
|
||||||
|
use Spatie\ElasticsearchQueryBuilder\Queries\Query;
|
||||||
|
|
||||||
|
class HasChildQuery implements Query
|
||||||
|
{
|
||||||
|
protected bool $innerHits = false;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected string $type,
|
||||||
|
protected Query $query,
|
||||||
|
protected string $scoreMode = 'sum'
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function create(string $type, Query $query, string $scoreMode = 'sum'): static
|
||||||
|
{
|
||||||
|
return new static($type, $query, $scoreMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Include inner_hits so the best-matching post ID is available in the response.
|
||||||
|
* ES returns the top-scoring child document per parent hit under inner_hits.best_post.
|
||||||
|
*/
|
||||||
|
public function withInnerHits(): static
|
||||||
|
{
|
||||||
|
$this->innerHits = true;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
$hasChild = [
|
||||||
|
'type' => $this->type,
|
||||||
|
'score_mode' => $this->scoreMode,
|
||||||
|
'query' => $this->query->toArray(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->innerHits) {
|
||||||
|
$hasChild['inner_hits'] = ['name' => 'best_post', 'size' => 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['has_child' => $hasChild];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,9 +26,10 @@ class DeletingJob extends Job
|
||||||
// Preparing body for storing.
|
// Preparing body for storing.
|
||||||
$body = $this->models->map(function (Model $model) {
|
$body = $this->models->map(function (Model $model) {
|
||||||
$document = $this->seeder->toDocument($model);
|
$document = $this->seeder->toDocument($model);
|
||||||
|
$routing = $this->seeder->routing($model);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
['delete' => ['_index' => $this->index, '_id' => $document->id]],
|
['delete' => ['_index' => $this->index, '_id' => $document->id, 'routing' => $routing]],
|
||||||
];
|
];
|
||||||
})->flatten(1);
|
})->flatten(1);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,13 @@ abstract class Job extends AbstractJob
|
||||||
|
|
||||||
public static ?string $onQueue = null;
|
public static ?string $onQueue = null;
|
||||||
|
|
||||||
public function __construct(protected Collection $models, protected Seeder $seeder)
|
/**
|
||||||
|
* @param string|null $targetIndex Explicit index name for blue-green builds.
|
||||||
|
* Defaults to the configured alias when null.
|
||||||
|
*/
|
||||||
|
public function __construct(protected Collection $models, protected Seeder $seeder, ?string $targetIndex = null)
|
||||||
{
|
{
|
||||||
$this->index = resolve('blomstra.search.elastic_index');
|
$this->index = $targetIndex ?? resolve('blomstra.search.elastic_index');
|
||||||
|
|
||||||
if (static::$onQueue) {
|
if (static::$onQueue) {
|
||||||
$this->onQueue(static::$onQueue);
|
$this->onQueue(static::$onQueue);
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,10 @@ class UpdateSearchJob extends Job
|
||||||
// Preparing body for storing.
|
// Preparing body for storing.
|
||||||
$body = $this->models->map(function (Model $model) {
|
$body = $this->models->map(function (Model $model) {
|
||||||
$document = $this->seeder->toDocument($model);
|
$document = $this->seeder->toDocument($model);
|
||||||
|
$routing = $this->seeder->routing($model);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
['index' => ['_index' => $this->index, '_id' => $document->id]],
|
['index' => ['_index' => $this->index, '_id' => $document->id, 'routing' => $routing]],
|
||||||
$document->toArray(),
|
$document->toArray(),
|
||||||
];
|
];
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,12 @@ use Flarum\Queue\AbstractJob;
|
||||||
class ViewsSearchJob extends AbstractJob
|
class ViewsSearchJob extends AbstractJob
|
||||||
{
|
{
|
||||||
protected string $index;
|
protected string $index;
|
||||||
|
protected string $documentType;
|
||||||
|
|
||||||
public function __construct(protected int $discussionId)
|
public function __construct(protected int $discussionId)
|
||||||
{
|
{
|
||||||
$this->index = resolve('blomstra.search.elastic_index');
|
$this->index = resolve('blomstra.search.elastic_index');
|
||||||
|
$this->documentType = resolve(DiscussionSerializer::class)->getType(new Discussion());
|
||||||
|
|
||||||
if (Job::$onQueue) {
|
if (Job::$onQueue) {
|
||||||
$this->onQueue(Job::$onQueue);
|
$this->onQueue(Job::$onQueue);
|
||||||
|
|
@ -38,16 +40,20 @@ class ViewsSearchJob extends AbstractJob
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$type = resolve(DiscussionSerializer::class)->getType(new Discussion());
|
$type = $this->documentType;
|
||||||
|
|
||||||
|
try {
|
||||||
$client->update([
|
$client->update([
|
||||||
'index' => $this->index,
|
'index' => $this->index,
|
||||||
'id' => "$type:{$this->discussionId}",
|
'id' => "$type:{$this->discussionId}",
|
||||||
|
'routing' => (string) $this->discussionId,
|
||||||
'retry_on_conflict' => 3,
|
'retry_on_conflict' => 3,
|
||||||
'ignore' => [404],
|
|
||||||
'body' => [
|
'body' => [
|
||||||
'doc' => ['view_count' => (int) $discussion->view_count],
|
'doc' => ['view_count' => (int) $discussion->view_count],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
} catch (\Elasticsearch\Common\Exceptions\Missing404Exception $e) {
|
||||||
|
// Document not yet indexed; will be picked up on next --seed-missing or --recreate.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,11 +116,9 @@ class Provider extends AbstractServiceProvider
|
||||||
$queue->pushOn(Job::$onQueue, new DeletingJob(Collection::make([$model]), $seeder));
|
$queue->pushOn(Job::$onQueue, new DeletingJob(Collection::make([$model]), $seeder));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (method_exists($seeder, 'viewingOn')) {
|
|
||||||
$seeder::viewingOn($events, function (int $discussionId) use ($queue) {
|
$seeder::viewingOn($events, function (int $discussionId) use ($queue) {
|
||||||
$queue->pushOn(Job::$onQueue, new ViewsSearchJob($discussionId));
|
$queue->pushOn(Job::$onQueue, new ViewsSearchJob($discussionId));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,12 @@
|
||||||
namespace Blomstra\Search\Save;
|
namespace Blomstra\Search\Save;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Fluent;
|
use Illuminate\Support\Fluent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property string $type
|
|
||||||
* @property string $id
|
* @property string $id
|
||||||
|
* @property int $rawId
|
||||||
* @property string $content
|
* @property string $content
|
||||||
* @property Carbon $created_at
|
* @property Carbon $created_at
|
||||||
* @property Carbon $updated_at
|
* @property Carbon $updated_at
|
||||||
|
|
@ -30,4 +31,13 @@ use Illuminate\Support\Fluent;
|
||||||
*/
|
*/
|
||||||
class Document extends Fluent
|
class Document extends Fluent
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Exclude `id` from the body sent to Elasticsearch. The document ID is
|
||||||
|
* already stored as `_id` by the bulk API action; duplicating it in
|
||||||
|
* `_source` wastes space and is redundant.
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return Arr::except($this->attributes, ['id']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@
|
||||||
namespace Blomstra\Search\Seeders;
|
namespace Blomstra\Search\Seeders;
|
||||||
|
|
||||||
use Blomstra\Search\Save\Document;
|
use Blomstra\Search\Save\Document;
|
||||||
|
use Flarum\Api\Serializer\DiscussionSerializer;
|
||||||
use Flarum\Api\Serializer\PostSerializer;
|
use Flarum\Api\Serializer\PostSerializer;
|
||||||
|
use Flarum\Discussion\Discussion;
|
||||||
use Flarum\Post\CommentPost;
|
use Flarum\Post\CommentPost;
|
||||||
use Flarum\Post\Event as Core;
|
use Flarum\Post\Event as Core;
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
|
|
@ -27,30 +29,29 @@ class CommentSeeder extends Seeder
|
||||||
return resolve(PostSerializer::class)->getType(new CommentPost());
|
return resolve(PostSerializer::class)->getType(new CommentPost());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function joinRelation(): string
|
||||||
|
{
|
||||||
|
return 'post';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function routing(Model $model): string
|
||||||
|
{
|
||||||
|
return (string) $model->discussion_id;
|
||||||
|
}
|
||||||
|
|
||||||
public function query(): Builder
|
public function query(): Builder
|
||||||
{
|
{
|
||||||
$includes = ['discussion'];
|
|
||||||
|
|
||||||
if ($this->extensionEnabled('flarum-tags')) {
|
|
||||||
$includes[] = 'discussion.tags';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->extensionEnabled('fof-byobu')) {
|
|
||||||
$includes[] = 'discussion.recipientUsers';
|
|
||||||
$includes[] = 'discussion.recipientGroups';
|
|
||||||
}
|
|
||||||
|
|
||||||
return CommentPost::query()
|
return CommentPost::query()
|
||||||
->whereNull('hidden_at')
|
->where('type', CommentPost::$type);
|
||||||
->where('type', CommentPost::$type)
|
|
||||||
->with($includes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function savingOn(Dispatcher $events, callable $callable)
|
public static function savingOn(Dispatcher $events, callable $callable)
|
||||||
{
|
{
|
||||||
$events->listen([
|
$events->listen([
|
||||||
Core\Posted::class,
|
Core\Posted::class,
|
||||||
Core\Revised::class
|
Core\Revised::class,
|
||||||
|
Core\Hidden::class,
|
||||||
|
Core\Restored::class,
|
||||||
], function ($event) use ($callable) {
|
], function ($event) use ($callable) {
|
||||||
$callable($event->post);
|
$callable($event->post);
|
||||||
});
|
});
|
||||||
|
|
@ -65,41 +66,31 @@ class CommentSeeder extends Seeder
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cached discussion type string (e.g. "discussions") — resolved once per job. */
|
||||||
|
private ?string $discussionType = null;
|
||||||
|
|
||||||
|
private function discussionType(): string
|
||||||
|
{
|
||||||
|
return $this->discussionType ??= resolve(DiscussionSerializer::class)->getType(new Discussion());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param CommentPost $model
|
* Post documents only need content for has_child matching and the join
|
||||||
|
* field to establish the parent-child relationship. All discussion-level
|
||||||
|
* fields (groups, tags, comment_count, etc.) live on the discussion
|
||||||
|
* document and are irrelevant here.
|
||||||
*
|
*
|
||||||
* @return Document
|
* @param CommentPost $model
|
||||||
*/
|
*/
|
||||||
public function toDocument(Model $model): Document
|
public function toDocument(Model $model): Document
|
||||||
{
|
{
|
||||||
$document = new Document([
|
return new Document([
|
||||||
'type' => $this->type(),
|
'join_field' => ['name' => $this->joinRelation(), 'parent' => "{$this->discussionType()}:{$model->discussion_id}"],
|
||||||
|
'discussion_id' => $model->discussion_id,
|
||||||
'id' => $this->type().':'.$model->id,
|
'id' => $this->type().':'.$model->id,
|
||||||
'rawId' => $model->id,
|
'rawId' => $model->id,
|
||||||
'content' => $model->content,
|
'content' => $model->content,
|
||||||
'created_at' => $model->created_at?->toAtomString(),
|
'is_hidden' => $model->hidden_at !== null,
|
||||||
'updated_at' => $model->edited_at?->toAtomString(),
|
|
||||||
'is_private' => $model->is_private,
|
|
||||||
'user_id' => $model->user_id,
|
|
||||||
'groups' => $this->groupsForDiscussion($model->discussion),
|
|
||||||
'comment_count' => $model->discussion->comment_count,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($this->extensionEnabled('flarum-tags')) {
|
|
||||||
$document['tags'] = $model->discussion->tags->pluck('id')->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->extensionEnabled('fof-byobu')) {
|
|
||||||
$document['recipient_users'] = $model->discussion->recipientUsers
|
|
||||||
->whereNull('removed_at')
|
|
||||||
->pluck('id')
|
|
||||||
->toArray();
|
|
||||||
$document['recipient_groups'] = $model->discussion->recipientGroups
|
|
||||||
->whereNull('removed_at')
|
|
||||||
->pluck('id')
|
|
||||||
->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $document;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,14 @@ use Flarum\Api\Serializer\DiscussionSerializer;
|
||||||
use Flarum\Discussion\Discussion;
|
use Flarum\Discussion\Discussion;
|
||||||
use Flarum\Discussion\Event as Core;
|
use Flarum\Discussion\Event as Core;
|
||||||
use Flarum\Extension\ExtensionManager;
|
use Flarum\Extension\ExtensionManager;
|
||||||
|
use Flarum\Group\Group;
|
||||||
|
use Flarum\Group\Permission;
|
||||||
|
use Flarum\Tags\Tag;
|
||||||
use FoF\Byobu\Events as Byobu;
|
use FoF\Byobu\Events as Byobu;
|
||||||
use FoF\DiscussionViews\Events\DiscussionWasViewed;
|
use FoF\DiscussionViews\Events\DiscussionWasViewed;
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class DiscussionSeeder extends Seeder
|
class DiscussionSeeder extends Seeder
|
||||||
|
|
@ -30,6 +34,16 @@ class DiscussionSeeder extends Seeder
|
||||||
return resolve(DiscussionSerializer::class)->getType(new Discussion());
|
return resolve(DiscussionSerializer::class)->getType(new Discussion());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function joinRelation(): string
|
||||||
|
{
|
||||||
|
return 'discussion';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function routing(Model $model): string
|
||||||
|
{
|
||||||
|
return (string) $model->id;
|
||||||
|
}
|
||||||
|
|
||||||
public function query(): Builder
|
public function query(): Builder
|
||||||
{
|
{
|
||||||
$includes = [];
|
$includes = [];
|
||||||
|
|
@ -99,7 +113,7 @@ class DiscussionSeeder extends Seeder
|
||||||
public function toDocument(Model $model): Document
|
public function toDocument(Model $model): Document
|
||||||
{
|
{
|
||||||
$document = new Document([
|
$document = new Document([
|
||||||
'type' => $this->type(),
|
'join_field' => $this->joinRelation(),
|
||||||
'id' => $this->type().':'.$model->id,
|
'id' => $this->type().':'.$model->id,
|
||||||
'rawId' => $model->id,
|
'rawId' => $model->id,
|
||||||
'content' => $model->title,
|
'content' => $model->title,
|
||||||
|
|
@ -136,4 +150,40 @@ class DiscussionSeeder extends Seeder
|
||||||
|
|
||||||
return $document;
|
return $document;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function groupsForDiscussion(Discussion $discussion): array
|
||||||
|
{
|
||||||
|
$permissions = collect();
|
||||||
|
|
||||||
|
$globalPermission = Permission::query()
|
||||||
|
->where('permission', 'viewForum')
|
||||||
|
->pluck('group_id');
|
||||||
|
|
||||||
|
if ($this->extensionEnabled('flarum-tags')) {
|
||||||
|
/** @var Collection $tags */
|
||||||
|
$tags = $discussion->tags;
|
||||||
|
|
||||||
|
$tagPermissions = Permission::query()
|
||||||
|
->whereIn(
|
||||||
|
'permission',
|
||||||
|
$tags->pluck('id')->map(fn (int $id) => "tag$id.viewForum")
|
||||||
|
)->get();
|
||||||
|
|
||||||
|
$permissions = $tags->map(function (Tag $tag) use ($tagPermissions) {
|
||||||
|
$permissions = $tagPermissions->where('permission', "tag$tag->id.viewForum");
|
||||||
|
|
||||||
|
if ($tag->is_restricted) {
|
||||||
|
$permissions = $permissions->add(['group_id' => Group::ADMINISTRATOR_ID]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $permissions->pluck('group_id');
|
||||||
|
})->flatten();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$discussion->is_private && $permissions->isEmpty()) {
|
||||||
|
$permissions = $globalPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $permissions->toArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,66 +13,38 @@
|
||||||
namespace Blomstra\Search\Seeders;
|
namespace Blomstra\Search\Seeders;
|
||||||
|
|
||||||
use Blomstra\Search\Save\Document;
|
use Blomstra\Search\Save\Document;
|
||||||
use Flarum\Discussion\Discussion;
|
|
||||||
use Flarum\Extension\ExtensionManager;
|
use Flarum\Extension\ExtensionManager;
|
||||||
use Flarum\Group\Group;
|
|
||||||
use Flarum\Group\Permission;
|
|
||||||
use Flarum\Tags\Tag;
|
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
abstract class Seeder
|
abstract class Seeder
|
||||||
{
|
{
|
||||||
abstract public function type(): string;
|
abstract public function type(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The join relation name for this document type ('discussion' or 'post').
|
||||||
|
* Used in the join_field mapping and for filtering in seed-missing checks.
|
||||||
|
*/
|
||||||
|
abstract public function joinRelation(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ES routing key for this model. Parent and child documents for the same
|
||||||
|
* discussion must share a routing key so ES places them on the same shard.
|
||||||
|
*/
|
||||||
|
abstract public function routing(Model $model): string;
|
||||||
|
|
||||||
abstract public function query(): Builder;
|
abstract public function query(): Builder;
|
||||||
|
|
||||||
abstract public static function savingOn(Dispatcher $events, callable $callable);
|
abstract public static function savingOn(Dispatcher $events, callable $callable);
|
||||||
|
|
||||||
abstract public static function deletingOn(Dispatcher $events, callable $callable);
|
abstract public static function deletingOn(Dispatcher $events, callable $callable);
|
||||||
|
|
||||||
|
/** No-op default; override in seeders that need to react to view events. */
|
||||||
|
public static function viewingOn(Dispatcher $events, callable $callable): void {}
|
||||||
|
|
||||||
abstract public function toDocument(Model $model): Document;
|
abstract public function toDocument(Model $model): Document;
|
||||||
|
|
||||||
protected function groupsForDiscussion(Discussion $discussion): array
|
|
||||||
{
|
|
||||||
$permissions = collect();
|
|
||||||
|
|
||||||
$globalPermission = Permission::query()
|
|
||||||
->where('permission', 'viewForum')
|
|
||||||
->pluck('group_id');
|
|
||||||
|
|
||||||
if ($this->extensionEnabled('flarum-tags')) {
|
|
||||||
/** @var Collection $tags */
|
|
||||||
$tags = $discussion->tags;
|
|
||||||
|
|
||||||
$tagPermissions = Permission::query()
|
|
||||||
->whereIn(
|
|
||||||
'permission',
|
|
||||||
$tags->pluck('id')->map(function (int $id) {
|
|
||||||
return "tag$id.viewForum";
|
|
||||||
})
|
|
||||||
)->get();
|
|
||||||
|
|
||||||
$permissions = $tags->map(function (Tag $tag) use ($tagPermissions) {
|
|
||||||
$permissions = $tagPermissions->where('permission', "tag$tag->id.viewForum");
|
|
||||||
|
|
||||||
if ($tag->is_restricted) {
|
|
||||||
$permissions = $permissions->add(['group_id' => Group::ADMINISTRATOR_ID]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $permissions->pluck('group_id');
|
|
||||||
})->flatten();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$discussion->is_private && $permissions->isEmpty()) {
|
|
||||||
$permissions = $globalPermission;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $permissions->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function extensionEnabled(string $extension): bool
|
protected function extensionEnabled(string $extension): bool
|
||||||
{
|
{
|
||||||
/** @var ExtensionManager $manager */
|
/** @var ExtensionManager $manager */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue