mirror of
https://github.com/jikan-me/jikan-rest.git
synced 2025-02-20 11:23:35 +08:00
185 lines
7.4 KiB
PHP
185 lines
7.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Contracts\Repository;
|
|
use App\JikanApiSearchableModel;
|
|
use App\Support\JikanConfig;
|
|
use Illuminate\Support\Str;
|
|
use Laravel\Scout\Builder;
|
|
use Typesense\Documents;
|
|
use App\Contracts\SearchAnalyticsService;
|
|
|
|
class TypeSenseScoutSearchService implements ScoutSearchService
|
|
{
|
|
private int $maxItemsPerPage;
|
|
|
|
private JikanConfig $jikanConfig;
|
|
|
|
public function __construct(private readonly Repository $repository,
|
|
JikanConfig $config,
|
|
private readonly TypesenseCollectionDescriptor $collectionDescriptor,
|
|
private readonly SearchAnalyticsService $searchAnalytics)
|
|
{
|
|
$this->maxItemsPerPage = (int) $config->maxResultsPerPage();
|
|
$this->jikanConfig = $config;
|
|
if ($this->maxItemsPerPage > 250) {
|
|
$this->maxItemsPerPage = 250;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes a search operation via Laravel Scout on the provided model class.
|
|
* @param string $q
|
|
* @param string|null $orderByField
|
|
* @param bool $sortDirectionDescending
|
|
* @return Builder
|
|
*/
|
|
public function search(string $q, ?string $orderByField = null,
|
|
bool $sortDirectionDescending = false): \Laravel\Scout\Builder
|
|
{
|
|
return $this->repository->search($q, $this->middleware($orderByField, $sortDirectionDescending));
|
|
}
|
|
|
|
private function middleware(?string $orderByField = null, bool $sortDirectionDescending = false): \Closure
|
|
{
|
|
return function (Documents $documents, string $query, array $options) use ($orderByField, $sortDirectionDescending) {
|
|
$options['exhaustive_search'] = $this->jikanConfig->exhaustiveSearch();
|
|
$options['search_cutoff_ms'] = $this->jikanConfig->searchCutOffMs();
|
|
// this will be ignored together with exhaustive_search set to "true"
|
|
$options['drop_tokens_threshold'] = $this->jikanConfig->dropTokensThreshold();
|
|
$options['typo_tokens_threshold'] = $this->jikanConfig->typoTokensThreshold();
|
|
$options['enable_highlight_v1'] = 'false';
|
|
$options['infix'] = 'fallback';
|
|
// prevent `Could not parse the filter query: unbalanced `&&` operands.` error
|
|
// this adds support for typesense v0.24.1
|
|
if (array_key_exists('filter_by', $options) && ($options['filter_by'] === ' && ' || $options['filter_by'] === '&&')) {
|
|
unset($options['filter_by']);
|
|
}
|
|
|
|
if (array_key_exists('per_page', $options) && $options['per_page'] > 250) {
|
|
$options['per_page'] = min($this->maxItemsPerPage, 250);
|
|
}
|
|
|
|
$modelInstance = $this->repository->createEntity();
|
|
$modelTitleAttributeName = "title";
|
|
|
|
if ($modelInstance instanceof JikanApiSearchableModel) {
|
|
$options = $this->setQueryByWeights($options, $modelInstance);
|
|
$options = $this->setSortOrder($options, $modelInstance);
|
|
$options = $this->overrideSortingOrder($options, $modelInstance, $orderByField, $sortDirectionDescending);
|
|
$modelTitleAttributeName = $modelInstance->getTitleAttributeName();
|
|
}
|
|
$options = $this->adaptToShortQueries($query, $modelTitleAttributeName, $options);
|
|
|
|
$results = $documents->search($options);
|
|
$this->recordSearchTelemetry($query, $results);
|
|
|
|
return $results;
|
|
};
|
|
}
|
|
|
|
private function adaptToShortQueries(string $query, string $modelTitleAttributeName, array $options): array
|
|
{
|
|
if (strlen($query) <= 3) {
|
|
$options['num_typos'] = 0;
|
|
$options['typo_tokens_threshold'] = 0;
|
|
$options['drop_tokens_threshold'] = 0;
|
|
$options['exhaustive_search'] = 'false';
|
|
$options['infix'] = 'off';
|
|
$options['prioritize_token_position'] = 'true';
|
|
|
|
if (array_key_exists("sort_by", $options)) {
|
|
if (Str::startsWith($options["sort_by"], "_text_match")) {
|
|
$options["sort_by"] = "_text_match:desc," . $modelTitleAttributeName .":asc";
|
|
} else {
|
|
// move text_match to the beginning if there is an orderby parameter set.
|
|
$options["sort_by"] = Str::replace("(buckets:". $this->jikanConfig->textMatchBuckets().")", "", $options["sort_by"]);
|
|
$parts = collect(explode(",", $options["sort_by"]));
|
|
$last = $parts->pop();
|
|
$parts = $parts->prepend($last);
|
|
$options["sort_by"] = $parts->implode(",");
|
|
}
|
|
} else {
|
|
$options["sort_by"] = "_text_match:desc," . $modelTitleAttributeName .":asc";
|
|
}
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
private function setQueryByWeights(array $options, JikanApiSearchableModel $modelInstance): array
|
|
{
|
|
$queryByWeights = $modelInstance->getTypeSenseQueryByWeights();
|
|
if (!is_null($queryByWeights)) {
|
|
$options['query_by_weights'] = $queryByWeights;
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
private function setSortOrder(array $options, JikanApiSearchableModel $modelInstance): array
|
|
{
|
|
$sortByFields = $modelInstance->getSearchIndexSortBy();
|
|
if (!is_null($sortByFields)) {
|
|
$sortBy = "";
|
|
foreach ($sortByFields as $f) {
|
|
$sortBy .= $f['field'] . ':' . $f['direction'];
|
|
$sortBy .= ',';
|
|
}
|
|
$sortBy = rtrim($sortBy, ',');
|
|
$options["sort_by"] = $sortBy;
|
|
} else if (is_null($sortByFields) && !array_key_exists("sort_by", $options)) {
|
|
$options["sort_by"] = "_text_match:desc";
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
private function overrideSortingOrder(array $options, JikanApiSearchableModel $modelInstance, ?string $orderByField, bool $sortDirectionDescending): array
|
|
{
|
|
$modelAttrNames = $this->collectionDescriptor->getSearchableAttributes($modelInstance);
|
|
|
|
// fixme: this shouldn't be here, but it's a quick fix for the time being
|
|
if ($orderByField === "aired.from") {
|
|
$orderByField = "start_date";
|
|
}
|
|
|
|
if ($orderByField === "aired.to") {
|
|
$orderByField = "end_date";
|
|
}
|
|
|
|
if ($orderByField === "published.from") {
|
|
$orderByField = "start_date";
|
|
}
|
|
|
|
if ($orderByField === "published.to") {
|
|
$orderByField = "end_date";
|
|
}
|
|
// fixme end
|
|
|
|
// override ordering field
|
|
if (!is_null($orderByField) && in_array($orderByField, $modelAttrNames)) {
|
|
$options['sort_by'] = "$orderByField:" . ($sortDirectionDescending ? "desc" : "asc") . ",_text_match(buckets:".$this->jikanConfig->textMatchBuckets()."):desc";
|
|
}
|
|
|
|
// override overall sorting direction
|
|
if (is_null($orderByField) && $sortDirectionDescending && array_key_exists("sort_by", $options) && Str::contains($options["sort_by"], "asc")) {
|
|
$options["sort_by"] = Str::replace("asc", "desc", $options["sort_by"]);
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
private function recordSearchTelemetry(string $query, array $typesenseApiResponse): void
|
|
{
|
|
$hits = collect($typesenseApiResponse["hits"]);
|
|
$this->searchAnalytics->logSearch(
|
|
$query,
|
|
$typesenseApiResponse["found"],
|
|
$hits->pluck('document')->values(),
|
|
$typesenseApiResponse["request_params"]["collection_name"]
|
|
);
|
|
}
|
|
}
|