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:
pushrbx 2022-06-18 15:03:50 +01:00
parent c310d0ce06
commit 1dbdc71d15
12 changed files with 223 additions and 28 deletions

17
app/Helpers/Guards.php Normal file
View 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.");
}
}
}

View File

@ -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');

View File

@ -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;
}
}
}

View File

@ -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'];
}

View File

@ -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'];
}

View File

@ -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;
}
}

View File

@ -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"
]
];
}
}

View File

@ -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");
}
}

View 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);
}
}

View 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()]);
});
}
}

View 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;
}

View 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);
});
}
}