mirror of
https://github.com/jikan-me/jikan-rest.git
synced 2025-02-20 11:23:35 +08:00
made search results more similar to the ones on MAL
- added more support for elasticsearch - added more options for typesense searches
This commit is contained in:
parent
c310d0ce06
commit
1dbdc71d15
17
app/Helpers/Guards.php
Normal file
17
app/Helpers/Guards.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
namespace App\Helpers;
|
||||
|
||||
use Jenssegers\Mongodb\Eloquent\Model;
|
||||
|
||||
abstract class Guards
|
||||
{
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
static function shouldBeMongoDbModel(object|string $modelClass): void
|
||||
{
|
||||
if (!in_array(Model::class, class_parents($modelClass))) {
|
||||
throw new \InvalidArgumentException("$modelClass should inherit from \Jenssegers\Mongodb\Eloquent\Model.");
|
||||
}
|
||||
}
|
||||
}
|
@ -3,12 +3,12 @@
|
||||
namespace App\Http\QueryBuilder;
|
||||
|
||||
use App\Http\QueryBuilder\Traits\PaginationParameterResolver;
|
||||
use App\Services\ScoutSearchService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use Laravel\Scout\Searchable;
|
||||
use Jenssegers\Mongodb\Eloquent\Model;
|
||||
use Typesense\Documents;
|
||||
|
||||
abstract class SearchQueryBuilder implements SearchQueryBuilderService
|
||||
{
|
||||
@ -18,12 +18,14 @@ abstract class SearchQueryBuilder implements SearchQueryBuilderService
|
||||
protected array $parameterNames = [];
|
||||
protected string $displayNameFieldName = "name";
|
||||
protected bool $searchIndexesEnabled;
|
||||
private ScoutSearchService $scoutSearchService;
|
||||
|
||||
private ?array $modelClassTraitsCache = null;
|
||||
|
||||
public function __construct(bool $searchIndexesEnabled)
|
||||
public function __construct(bool $searchIndexesEnabled, ScoutSearchService $scoutSearchService)
|
||||
{
|
||||
$this->searchIndexesEnabled = $searchIndexesEnabled;
|
||||
$this->scoutSearchService = $scoutSearchService;
|
||||
}
|
||||
|
||||
protected function getParametersFromRequest(Request $request): Collection
|
||||
@ -65,24 +67,17 @@ abstract class SearchQueryBuilder implements SearchQueryBuilderService
|
||||
inherits from \Jenssegers\Mongodb\Eloquent\Model.");
|
||||
}
|
||||
|
||||
if ($this->isSearchIndexUsed() && !empty($requestParameters->get("q"))) {
|
||||
if (env('SCOUT_DRIVER') == 'typesense')
|
||||
{
|
||||
// if search index is typesense, let's enable exhaustive search
|
||||
// which will make Typesense consider all variations of prefixes and typo corrections of the words
|
||||
// in the query exhaustively, without stopping early when enough results are found.
|
||||
return $modelClass::search($requestParameters["q"], function(Documents $documents, string $query, array $options) {
|
||||
$options['exhaustive_search'] = true;
|
||||
$q = $requestParameters->get("q");
|
||||
|
||||
return $documents->search($options);
|
||||
});
|
||||
}
|
||||
return $modelClass::search($requestParameters->get("q"));
|
||||
if ($this->isSearchIndexUsed() && !empty($q)) {
|
||||
$builder = $this->scoutSearchService->search($modelClass, $q);
|
||||
} else {
|
||||
// If "q" is not set, OR search indexes are disabled, we just get a query builder for the model.
|
||||
// This way we can have a single place where we get the query builder from.
|
||||
$builder = $modelClass::query();
|
||||
}
|
||||
|
||||
// If "q" is not set, OR search indexes are disabled, we just get a query builder for the model.
|
||||
// This way we can have a single place where we get the query builder from.
|
||||
return $modelClass::query();
|
||||
return $builder;
|
||||
}
|
||||
|
||||
public function isSearchIndexUsed(): bool
|
||||
@ -127,7 +122,7 @@ abstract class SearchQueryBuilder implements SearchQueryBuilderService
|
||||
// if search index is enabled, this way we only do the full-text search on the index, and filter further in mongodb.
|
||||
// the $results variable can be a Builder from the Mongodb Eloquent or from Scout. Only Laravel\Scout\Builder
|
||||
// has a query method which will result in a Mongodb Eloquent Builder.
|
||||
return $this->isScoutBuilder($results) ? $results->query(function(\Illuminate\Database\Eloquent\Builder $query) use($requestParameters) {
|
||||
return $this->isScoutBuilder($results) ? $results->query(function (\Illuminate\Database\Eloquent\Builder $query) use ($requestParameters) {
|
||||
$letter = $requestParameters->get('letter');
|
||||
$q = $requestParameters->get('q');
|
||||
|
||||
|
@ -6,6 +6,7 @@ use App\GenreAnime;
|
||||
use App\GenreManga;
|
||||
use App\Magazine;
|
||||
use App\Producer;
|
||||
use App\Services\ScoutSearchService;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class SimpleSearchQueryBuilder extends SearchQueryBuilder
|
||||
@ -17,12 +18,13 @@ class SimpleSearchQueryBuilder extends SearchQueryBuilder
|
||||
'mal_id', 'name', 'count'
|
||||
];
|
||||
|
||||
public function __construct(string $identifier, string|object $modelClass, bool $searchIndexesEnabled)
|
||||
public function __construct(string $identifier, string|object $modelClass, bool $searchIndexesEnabled,
|
||||
ScoutSearchService $scoutSearchService)
|
||||
{
|
||||
if (!in_array($modelClass, [GenreAnime::class, GenreManga::class, Producer::class, Magazine::class])) {
|
||||
throw new \InvalidArgumentException("Not supported model class has been provided.");
|
||||
}
|
||||
parent::__construct($searchIndexesEnabled);
|
||||
parent::__construct($searchIndexesEnabled, $scoutSearchService);
|
||||
$this->modelClass = $modelClass;
|
||||
$this->identifier = $identifier;
|
||||
}
|
||||
@ -46,4 +48,4 @@ class SimpleSearchQueryBuilder extends SearchQueryBuilder
|
||||
{
|
||||
return $this->identifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,15 +3,16 @@
|
||||
namespace App\Http\QueryBuilder;
|
||||
|
||||
use App\Http\QueryBuilder\Traits\TopQueryFilterResolver;
|
||||
use App\Services\ScoutSearchService;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class TopAnimeQueryBuilder extends AnimeSearchQueryBuilder
|
||||
{
|
||||
use TopQueryFilterResolver;
|
||||
|
||||
public function __construct(bool $searchIndexesEnabled)
|
||||
public function __construct(bool $searchIndexesEnabled, ScoutSearchService $scoutSearchService)
|
||||
{
|
||||
parent::__construct($searchIndexesEnabled);
|
||||
parent::__construct($searchIndexesEnabled, $scoutSearchService);
|
||||
$this->filterMap = ['airing', 'upcoming', 'bypopularity', 'favorite'];
|
||||
}
|
||||
|
||||
|
@ -3,15 +3,16 @@
|
||||
namespace App\Http\QueryBuilder;
|
||||
|
||||
use App\Http\QueryBuilder\Traits\TopQueryFilterResolver;
|
||||
use App\Services\ScoutSearchService;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class TopMangaQueryBuilder extends MangaSearchQueryBuilder
|
||||
{
|
||||
use TopQueryFilterResolver;
|
||||
|
||||
public function __construct(bool $searchIndexesEnabled)
|
||||
public function __construct(bool $searchIndexesEnabled, ScoutSearchService $scoutSearchService)
|
||||
{
|
||||
parent::__construct($searchIndexesEnabled);
|
||||
parent::__construct($searchIndexesEnabled, $scoutSearchService);
|
||||
$this->filterMap = ['publishing', 'upcoming', 'bypopularity', 'favorite'];
|
||||
}
|
||||
|
||||
|
@ -61,4 +61,23 @@ abstract class JikanApiSearchableModel extends JikanApiModel implements Typesens
|
||||
{
|
||||
return 'mal_id';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns what weights to use on query_by fields.
|
||||
* https://typesense.org/docs/0.23.0/api/documents.html#search-parameters
|
||||
* @return string|null
|
||||
*/
|
||||
public function getTypeSenseQueryByWeights(): string|null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns which fields the search index should sort on when searching
|
||||
* @return array|null
|
||||
*/
|
||||
public function getSearchIndexSortBy(): array|null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -112,4 +112,24 @@ class Manga extends JikanApiSearchableModel
|
||||
'title_synonyms'
|
||||
];
|
||||
}
|
||||
|
||||
public function getTypeSenseQueryByWeights(): string|null
|
||||
{
|
||||
// this way title_synonyms will rank lower in search results
|
||||
return "1,1,1,2";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns which fields the search index should sort on when searching
|
||||
* @return array|null
|
||||
*/
|
||||
public function getSearchIndexSortBy(): array|null
|
||||
{
|
||||
return [
|
||||
[
|
||||
"field" => "popularity",
|
||||
"direction" => "asc"
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -15,6 +15,10 @@ use App\Http\QueryBuilder\TopMangaQueryBuilder;
|
||||
use App\Macros\To2dArrayWithDottedKeys;
|
||||
use App\Magazine;
|
||||
use App\Producer;
|
||||
use App\Services\DefaultScoutSearchService;
|
||||
use App\Services\ElasticScoutSearchService;
|
||||
use App\Services\ScoutSearchService;
|
||||
use App\Services\TypeSenseScoutSearchService;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
@ -38,6 +42,15 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(ScoutSearchService::class, function($app) {
|
||||
$scoutDriver = $this->getSearchIndexDriver($app);
|
||||
return match ($scoutDriver) {
|
||||
"typesense" => new TypeSenseScoutSearchService(),
|
||||
"Matchish\ScoutElasticSearch\Engines\ElasticSearchEngine" => new ElasticScoutSearchService(),
|
||||
default => new DefaultScoutSearchService()
|
||||
};
|
||||
});
|
||||
|
||||
$queryBuilders = [
|
||||
AnimeSearchQueryBuilder::class,
|
||||
MangaSearchQueryBuilder::class,
|
||||
@ -86,7 +99,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
return new SimpleSearchQueryBuilder(
|
||||
$simpleQueryBuilder["identifier"],
|
||||
$simpleQueryBuilder["modelClass"],
|
||||
$searchIndexesEnabled
|
||||
$searchIndexesEnabled,
|
||||
$app->make(ScoutSearchService::class)
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -121,12 +135,17 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
return function($app) use($queryBuilderClass) {
|
||||
$searchIndexesEnabled = $this->getSearchIndexesEnabledConfig($app);
|
||||
return new $queryBuilderClass($searchIndexesEnabled);
|
||||
return new $queryBuilderClass($searchIndexesEnabled, $app->make(ScoutSearchService::class));
|
||||
};
|
||||
}
|
||||
|
||||
private function getSearchIndexesEnabledConfig($app): bool
|
||||
{
|
||||
return $app["config"]->get("scout.driver") != "null";
|
||||
return $this->getSearchIndexDriver($app) != "null";
|
||||
}
|
||||
|
||||
private function getSearchIndexDriver($app): string
|
||||
{
|
||||
return $app["config"]->get("scout.driver");
|
||||
}
|
||||
}
|
||||
|
15
app/Services/DefaultScoutSearchService.php
Normal file
15
app/Services/DefaultScoutSearchService.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Helpers\Guards;
|
||||
|
||||
class DefaultScoutSearchService implements ScoutSearchService
|
||||
{
|
||||
public function search(object|string $modelClass, string $q): \Laravel\Scout\Builder
|
||||
{
|
||||
Guards::shouldBeMongoDbModel($modelClass);
|
||||
|
||||
return $modelClass::search($q);
|
||||
}
|
||||
}
|
43
app/Services/ElasticScoutSearchService.php
Normal file
43
app/Services/ElasticScoutSearchService.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\JikanApiSearchableModel;
|
||||
use ONGR\ElasticsearchDSL\Sort\FieldSort;
|
||||
|
||||
class ElasticScoutSearchService implements ScoutSearchService
|
||||
{
|
||||
/**
|
||||
* Executes a search operation via Laravel Scout on the provided model class.
|
||||
* @param object|string $modelClass
|
||||
* @param string $q
|
||||
* @return \Laravel\Scout\Builder
|
||||
* @throws \Elastic\Elasticsearch\Exception\ServerResponseException
|
||||
* @throws \Elastic\Elasticsearch\Exception\ClientResponseException
|
||||
* @throws \Elastic\Elasticsearch\Exception\MissingParameterException
|
||||
*/
|
||||
public function search(object|string $modelClass, string $q): \Laravel\Scout\Builder
|
||||
{
|
||||
return $modelClass::search($q, function(\Elastic\ElasticSearch\Client $client, \ONGR\ElasticsearchDSL\Search $body) use ($modelClass) {
|
||||
$modelInstance = new $modelClass;
|
||||
|
||||
if ($modelInstance instanceof JikanApiSearchableModel) {
|
||||
// if the model specifies search index sort order, use it
|
||||
$sortByFields = $modelInstance->getSearchIndexSortBy();
|
||||
if (!is_null($sortByFields)) {
|
||||
foreach ($sortByFields as $f) {
|
||||
$direction = match ($f['direction']) {
|
||||
'asc' => FieldSort::ASC,
|
||||
'desc' => FieldSort::DESC,
|
||||
};
|
||||
|
||||
$sort = new FieldSort($f['field'], ['order' => $direction]);
|
||||
$body->addSort($sort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $client->search(['index' => $modelInstance->searchableAs(), 'body' => $body->toArray()]);
|
||||
});
|
||||
}
|
||||
}
|
14
app/Services/ScoutSearchService.php
Normal file
14
app/Services/ScoutSearchService.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
interface ScoutSearchService
|
||||
{
|
||||
/**
|
||||
* Executes a search operation via Laravel Scout on the provided model class.
|
||||
* @param object|string $modelClass
|
||||
* @param string $q
|
||||
* @return \Laravel\Scout\Builder
|
||||
*/
|
||||
public function search(object|string $modelClass, string $q): \Laravel\Scout\Builder;
|
||||
}
|
49
app/Services/TypeSenseScoutSearchService.php
Normal file
49
app/Services/TypeSenseScoutSearchService.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\JikanApiSearchableModel;
|
||||
use Typesense\Documents;
|
||||
|
||||
class TypeSenseScoutSearchService implements ScoutSearchService
|
||||
{
|
||||
/**
|
||||
* Executes a search operation via Laravel Scout on the provided model class.
|
||||
* @param object|string $modelClass
|
||||
* @param string $q
|
||||
* @return \Laravel\Scout\Builder
|
||||
* @throws \Http\Client\Exception
|
||||
* @throws \Typesense\Exceptions\TypesenseClientError
|
||||
*/
|
||||
public function search(object|string $modelClass, string $q): \Laravel\Scout\Builder
|
||||
{
|
||||
return $modelClass::search($q, function (Documents $documents, string $query, array $options) use ($modelClass) {
|
||||
// let's enable exhaustive search
|
||||
// which will make Typesense consider all variations of prefixes and typo corrections of the words
|
||||
// in the query exhaustively, without stopping early when enough results are found.
|
||||
$options['exhaustive_search'] = true;
|
||||
$modelInstance = new $modelClass;
|
||||
// get the weights of the query_by fields, if they are provided by the model.
|
||||
if ($modelInstance instanceof JikanApiSearchableModel) {
|
||||
$queryByWeights = $modelInstance->getTypeSenseQueryByWeights();
|
||||
if (!is_null($queryByWeights)) {
|
||||
$options['query_by_weights'] = $queryByWeights;
|
||||
}
|
||||
|
||||
// if the model specifies search index sort order, use it
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
return $documents->search($options);
|
||||
});
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user