Files
animethemes-server/app/Scout/Elasticsearch/Elasticsearch.php
T

240 lines
8.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Scout\Elasticsearch;
use App\Concerns\Actions\Http\Api\AggregatesFields;
use App\Concerns\Actions\Http\Api\ConstrainsEagerLoads;
use App\Enums\Http\Api\Paging\PaginationStrategy;
use App\Http\Api\Query\Query;
use App\Http\Api\Schema\EloquentSchema;
use App\Http\Api\Scope\ScopeParser;
use App\Models\List\Playlist;
use App\Models\Wiki\Anime;
use App\Models\Wiki\Anime\AnimeTheme;
use App\Models\Wiki\Anime\Theme\AnimeThemeEntry;
use App\Models\Wiki\Artist;
use App\Models\Wiki\Series;
use App\Models\Wiki\Song;
use App\Models\Wiki\Studio;
use App\Models\Wiki\Synonym;
use App\Models\Wiki\Video;
use App\Scout\Criteria as SearchCriteria;
use App\Scout\Elasticsearch\Api\Criteria\Filter\Criteria;
use App\Scout\Elasticsearch\Api\Parser\FilterParser;
use App\Scout\Elasticsearch\Api\Parser\PagingParser;
use App\Scout\Elasticsearch\Api\Parser\SortParser;
use App\Scout\Elasticsearch\Api\Query\ElasticQuery;
use App\Scout\Elasticsearch\Api\Query\List\PlaylistQuery;
use App\Scout\Elasticsearch\Api\Query\Wiki\Anime\Theme\EntryQuery;
use App\Scout\Elasticsearch\Api\Query\Wiki\Anime\ThemeQuery;
use App\Scout\Elasticsearch\Api\Query\Wiki\AnimeQuery;
use App\Scout\Elasticsearch\Api\Query\Wiki\ArtistQuery;
use App\Scout\Elasticsearch\Api\Query\Wiki\SeriesQuery;
use App\Scout\Elasticsearch\Api\Query\Wiki\SongQuery;
use App\Scout\Elasticsearch\Api\Query\Wiki\StudioQuery;
use App\Scout\Elasticsearch\Api\Query\Wiki\SynonymQuery;
use App\Scout\Elasticsearch\Api\Query\Wiki\VideoQuery;
use App\Scout\Elasticsearch\Api\Schema\Schema;
use App\Scout\Search;
use Closure;
use Elastic\Client\ClientBuilderInterface;
use Elastic\ScoutDriverPlus\Builders\BoolQueryBuilder;
use Elastic\ScoutDriverPlus\Builders\SearchParametersBuilder;
use Elastic\ScoutDriverPlus\Exceptions\QueryBuilderValidationException;
use Exception;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;
class Elasticsearch extends Search
{
use AggregatesFields;
use ConstrainsEagerLoads;
protected SearchParametersBuilder $elasticBuilder;
/**
* Is the ES instance reachable?
*/
protected bool $alive;
public function __construct(ClientBuilderInterface $builder, SearchCriteria $criteria, protected ?Model $model = null)
{
parent::__construct($criteria);
try {
$this->alive = $builder->default()->ping()->asBool();
} catch (Exception $exception) {
Log::error($exception->getMessage());
$this->alive = false;
}
}
/**
* Is the ES instance reachable?
*/
public function isAlive(): bool
{
return $this->alive;
}
/**
* Should the search be performed?
*/
public function shouldSearch(): bool
{
return $this->isAlive();
}
/**
* Perform the search.
*/
public function searchViaJSONAPI(
Query $query,
EloquentSchema $schema,
PaginationStrategy $paginationStrategy
): Collection|Paginator {
$elasticSchema = $this->elasticSchema($schema);
// initialize builder for matches
$searchBuilder = static::elasticQuery($elasticSchema->model()::class)->build($this->criteria);
$this->elasticBuilder = $searchBuilder;
// load aggregate fields
$this->elasticBuilder->refineModels(function (EloquentBuilder $searchModelBuilder) use ($query, $schema): void {
$this->withAggregates($searchModelBuilder, $query, $schema);
});
// eager load relations with constraints
$this->elasticBuilder = $this->elasticBuilder->load($this->constrainEagerLoads($query, $schema));
// apply filters
$filterBuilder = new BoolQueryBuilder();
$scope = ScopeParser::parse($schema->type());
foreach ($query->getFilterCriteria() as $filterCriterion) {
$elasticFilterCriteria = FilterParser::parse($filterCriterion);
if ($elasticFilterCriteria instanceof Criteria) {
foreach ($elasticSchema->filters() as $filter) {
if ($filterCriterion->shouldFilter($filter, $scope)) {
$filterBuilder = $elasticFilterCriteria->filter($filterBuilder, $filter, $query);
}
}
}
}
try {
$this->elasticBuilder->postFilter($filterBuilder);
} catch (QueryBuilderValidationException) {
// There doesn't appear to be a way to check if any filters have been set in the filter builder
}
// apply sorts
$sorts = [];
foreach ($query->getSortCriteria() as $sortCriterion) {
$elasticSortCriteria = SortParser::parse($sortCriterion);
if ($elasticSortCriteria instanceof Api\Criteria\Sort\Criteria) {
foreach ($elasticSchema->sorts() as $sort) {
if ($sortCriterion->shouldSort($sort, $scope)) {
$sorts[] = $elasticSortCriteria->sort($sort);
}
}
}
}
if ($sorts !== []) {
$this->elasticBuilder->sortRaw($sorts);
}
// paginate
$paginationCriteria = $query->getPagingCriteria($paginationStrategy);
$elasticPaginationCriteria = PagingParser::parse($paginationCriteria);
return $elasticPaginationCriteria instanceof Api\Criteria\Paging\Criteria
? $elasticPaginationCriteria->paginate($this->elasticBuilder)
: $this->elasticBuilder->execute()->models();
}
/**
* @param Closure(EloquentBuilder): void $callback
* @param array<string, array{direction: string, isString: bool, relation: ?string}> $sorts
*/
public function search(?Closure $callback = null, int $perPage = 15, int $page = 1, array $sorts = []): LengthAwarePaginator
{
throw_unless($this->model instanceof Model, RuntimeException::class, 'Model must be provided for search.');
$searchBuilder = static::elasticQuery($this->model::class)->build($this->criteria);
$this->elasticBuilder = $searchBuilder;
// Resolve callback.
if ($callback instanceof Closure) {
$this->elasticBuilder->refineModels($callback);
}
// Resolve sorting.
$sortRaw = [];
foreach ($sorts as $column => $data) {
$nested = Arr::get($data, 'direction');
if (Arr::get($data, 'isString') === true) {
if ($relation = Arr::get($data, 'relation')) {
$column = $relation.'.'.$column.'_keyword';
$nested = [
'order' => $nested,
'nested' => [
'path' => $relation,
],
];
} else {
$column .= '.keyword';
}
}
$sortRaw[$column] = $nested;
}
if ($sortRaw !== []) {
$this->elasticBuilder->sortRaw($sortRaw);
}
return $this->elasticBuilder->paginate($perPage, page: $page)->onlyModels();
}
/**
* @param class-string<Model> $model
*/
public static function elasticQuery(string $model): ElasticQuery
{
return match ($model) {
Playlist::class => new PlaylistQuery(),
AnimeThemeEntry::class => new EntryQuery(),
AnimeTheme::class => new ThemeQuery(),
Anime::class => new AnimeQuery(),
Artist::class => new ArtistQuery(),
Series::class => new SeriesQuery(),
Song::class => new SongQuery(),
Studio::class => new StudioQuery(),
Synonym::class => new SynonymQuery(),
Video::class => new VideoQuery(),
default => throw new RuntimeException("No ElasticQuery defined for model: {$model}"),
};
}
/**
* Resolve Elasticsearch schema from Eloquent schema.
*/
private function elasticSchema(EloquentSchema $schema): Schema
{
$elasticSchemaClass = Str::of($schema::class)
->replace('Http', 'Scout\\Elasticsearch')
->__toString();
return new $elasticSchemaClass();
}
}