feat(search): add Typesense support (#1146)

This commit is contained in:
Kyrch
2026-03-31 15:32:32 -03:00
committed by GitHub
parent fa8d223b4b
commit c42771a411
80 changed files with 1843 additions and 487 deletions
+11 -10
View File
@@ -87,16 +87,6 @@ DUMP_DISK_ROOT=
DUMP_URL=http://localhost
DUMP_PATH=/dump
# elastic client
ELASTIC_CONNECTION=default
ELASTIC_HOST=http://localhost:9200
# elastic driver
ELASTIC_SCOUT_DRIVER_REFRESH_DOCUMENTS=false
# elastic migrations
ELASTIC_MIGRATIONS_TABLE=elastic_migrations
# external profile
USER_MAX_PROFILES=5
@@ -240,9 +230,20 @@ SCOUT_DRIVER=null
SCOUT_PREFIX=
SCOUT_QUEUE=false
SCOUT_IDENTIFY=false
ALGOLIA_APP_ID=
ALGOLIA_SECRET=
ELASTIC_CONNECTION=default
ELASTIC_HOST=http://localhost:9200
ELASTIC_SCOUT_DRIVER_REFRESH_DOCUMENTS=false
ELASTIC_MIGRATIONS_TABLE=elastic_migrations
TYPESENSE_HOST=localhost
TYPESENSE_PORT=8108
TYPESENSE_PROTOCOL=http
TYPESENSE_API_KEY=
# secure headers
ENABLE_CLEAR_SITE_DATA=false
ENABLE_HSTS=false
+11 -10
View File
@@ -88,16 +88,6 @@ DUMP_DISK_ROOT=
DUMP_URL=http://localhost
DUMP_PATH=/dump
# elastic client
ELASTIC_CONNECTION=default
ELASTIC_HOST=http://elasticsearch:9200
# elastic driver
ELASTIC_SCOUT_DRIVER_REFRESH_DOCUMENTS=false
# elastic migrations
ELASTIC_MIGRATIONS_TABLE=elastic_migrations
# ffmpeg
FFMPEG_BINARIES=
FFPROBE_BINARIES=
@@ -238,9 +228,20 @@ SCOUT_DRIVER=elastic
SCOUT_PREFIX=
SCOUT_QUEUE=false
SCOUT_IDENTIFY=false
ALGOLIA_APP_ID=
ALGOLIA_SECRET=
ELASTIC_CONNECTION=default
ELASTIC_HOST=http://elasticsearch:9200
ELASTIC_SCOUT_DRIVER_REFRESH_DOCUMENTS=false
ELASTIC_MIGRATIONS_TABLE=elastic_migrations
TYPESENSE_HOST=localhost
TYPESENSE_PORT=8108
TYPESENSE_PROTOCOL=http
TYPESENSE_API_KEY=
# secure headers
ENABLE_CLEAR_SITE_DATA=false
ENABLE_HSTS=false
+8 -6
View File
@@ -25,7 +25,7 @@ This project is powered by [**Laravel**](https://laravel.com/), a PHP framework
- [Extra Configuration](#extra-configuration)
- [Feature Flags](#feature-flags)
- [Users](#users)
- [Elasticsearch](#elasticsearch)
- [Search](#search)
- [Local Storage](#local-storage)
- [Contributing](#contributing)
- [Resources](#resources)
@@ -113,17 +113,19 @@ $user = User::factory()->create(['name' => 'User Name', 'email' => 'example@exam
$user->assignRole('Admin');
```
### Elasticsearch
### Search
If we want to enable scout, we need to configure Elasticsearch.
If we want to enable scout, we need to configure a search engine (either Elasticsearch or Typesense).
If we have installed Elasticsearch, migrate and import models into our indices using:
Change the `SCOUT_DRIVER` variable in `.env` to either "elastic" or "typesense". Add additional configuration like host and port.
Migrate and import models into our indices using:
```sh
# Run the elastic migrations
# Elasticsearch: run the elastic migrations
php artisan elastic:migrate
# Import Models with a seeder
# Elasticsearch and Typesense: Import Models with a seeder
php artisan db:seed --class="Database\Seeders\Scout\ImportModelsSeeder"
```
+11 -14
View File
@@ -14,8 +14,8 @@ use App\GraphQL\Criteria\Sort\SortCriteria;
use App\GraphQL\Schema\Enums\SortableColumns;
use App\GraphQL\Schema\Types\BaseType;
use App\Rules\GraphQL\Argument\FirstArgumentRule;
use App\Search\Criteria;
use App\Search\Search;
use App\Scout\Criteria;
use App\Scout\Search;
use GraphQL\Type\Definition\ResolveInfo;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder;
@@ -46,16 +46,17 @@ class IndexAction
{
$criteria = new Criteria(Arr::get($args, 'search'));
$searchBuilder = Search::search($builder->getModel(), $criteria)
->passToEloquentBuilder(function (Builder $builder) use ($args, $type, $resolveInfo): void {
$this->withAggregates($builder, $args, $this->getSelection($resolveInfo), $type);
$searchBuilder = Search::getSearch($builder->getModel(), $criteria);
$this->filter($builder, $args, $type);
$eloquentCallback = function (Builder $builder) use ($args, $type, $resolveInfo): void {
$this->withAggregates($builder, $args, $this->getSelection($resolveInfo), $type);
$this->sort($builder, $args, $type);
$this->filter($builder, $args, $type);
$this->constrainEagerLoads($builder, $resolveInfo, $type);
});
$this->sort($builder, $args, $type);
$this->constrainEagerLoads($builder, $resolveInfo, $type);
};
// Note: First for searching must not be too high.
$first = min(100, Arr::get($args, 'first'));
@@ -65,8 +66,6 @@ class IndexAction
'first' => ['required', 'integer', 'min:1', new FirstArgumentRule()],
])->validate();
$searchBuilder->withPagination($first, $page);
$sorts = Arr::get($args, SortArgument::ARGUMENT, []);
$criteria = Arr::get(new SortableColumns($type)->getAttributes(), 'criteria');
@@ -93,8 +92,6 @@ class IndexAction
}
}
$searchBuilder->withSort($sortsRaw);
return $searchBuilder->execute();
return $searchBuilder->search($eloquentCallback, $first, $page, $sortsRaw);
}
}
+5 -13
View File
@@ -12,12 +12,10 @@ use App\Http\Api\Query\Query;
use App\Http\Api\Schema\EloquentSchema;
use App\Http\Api\Schema\Schema;
use App\Http\Api\Scope\ScopeParser;
use App\Scout\Elasticsearch\Elasticsearch;
use App\Scout\Search;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Config;
use RuntimeException;
@@ -64,10 +62,12 @@ class IndexAction
Schema $schema,
PaginationStrategy $paginationStrategy = PaginationStrategy::OFFSET
): Collection|Paginator {
$search = $this->getSearch();
if ($query->hasSearchCriteria() && $schema instanceof EloquentSchema) {
$search = Search::getSearch($schema->model(), $query->getSearchCriteria());
if ($search instanceof Search && $search->shouldSearch($query) && $schema instanceof EloquentSchema) {
return $search->search($query, $schema, $paginationStrategy);
if ($search->shouldSearch()) {
return $search->searchViaJSONAPI($query, $schema, $paginationStrategy);
}
}
// Let developer know why search can't be performed
@@ -75,12 +75,4 @@ class IndexAction
$term = $query->getSearchCriteria()?->getTerm();
throw new RuntimeException("Can't search for term '$term' with driver '$driver' and type '{$schema->type()}'");
}
protected function getSearch(): ?Search
{
return match (Config::get('scout.driver')) {
'elastic' => App::make(Elasticsearch::class),
default => null,
};
}
}
@@ -83,6 +83,7 @@ class OptimizeImageAction
protected function handleFFmpeg(): ?string
{
$filesToDelete = [];
$imagePath = null;
try {
Storage::disk('local')->put(
-45
View File
@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Contracts\Search;
use App\Search\Criteria;
use Closure;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Model;
interface SearchDriver
{
/**
* Build the query.
*/
public static function search(Model $model, Criteria $criteria): SearchDriver;
/**
* Paginate the results.
*/
public function withPagination(int $perPage, int $page): static;
/**
* @param array<string, array{direction: string, isString: bool, relation: ?string}> $sorts
*/
public function withSort(array $sorts): static;
/**
* Run a callback through the Eloquent query.
*/
public function passToEloquentBuilder(Closure $callback): SearchDriver;
/**
* Execute the search and get the resulting models.
*/
public function execute(): Paginator;
/**
* Get the keys of the retrieved models.
*
* @return int[]
*/
public function keys(): array;
}
+4 -4
View File
@@ -9,8 +9,8 @@ use App\Contracts\Models\Nameable;
use App\Enums\Http\Api\Filter\ComparisonOperator;
use App\Filament\Resources\BaseResource;
use App\Models\Auth\User;
use App\Search\Criteria;
use App\Search\Search;
use App\Scout\Criteria;
use App\Scout\Search;
use Filament\Schemas\Schema;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;
@@ -73,8 +73,8 @@ class BelongsTo extends Select
return $this
->getSearchResultsUsing(
fn (string $search) => collect(
Search::search($modelClass, new Criteria($this->escapeReservedChars($search)))
->execute()
Search::getSearch($modelClass, new Criteria($this->escapeReservedChars($search)))
->search()
->items()
)
->mapWithKeys(fn (Model $model): array => [$model->getKey() => static::getSearchLabelWithBlade($model, $this->withSubtitle)])
+4 -5
View File
@@ -6,8 +6,8 @@ namespace App\Filament\Components\Fields;
use App\Contracts\Models\Nameable;
use App\Filament\RelationManagers\BaseRelationManager;
use App\Search\Criteria;
use App\Search\Search;
use App\Scout\Criteria;
use App\Scout\Search;
use Filament\Forms\Components\Select as ComponentsSelect;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@@ -29,8 +29,8 @@ class Select extends ComponentsSelect
->getOptionLabelUsing(fn ($state): string => is_null($state) ? '' : BelongsTo::getSearchLabelWithBlade($modelClass::query()->find($state)))
->getSearchResultsUsing(
fn (string $search) => collect(
Search::search($modelClass, new Criteria($this->escapeReservedChars($search)))
->passToEloquentBuilder(function (Builder $query) use ($loadRelation, $livewire): void {
Search::getSearch($modelClass, new Criteria($this->escapeReservedChars($search)))
->search(function (Builder $query) use ($loadRelation, $livewire): void {
$query->with($loadRelation ?? []);
if (! ($livewire instanceof BaseRelationManager)
@@ -41,7 +41,6 @@ class Select extends ComponentsSelect
// This is necessary to prevent already attached records from being returned on search.
$query->whereDoesntHave($livewire->getTable()->getInverseRelationship(), fn (Builder $query) => $query->whereKey($livewire->getOwnerRecord()->getKey()));
})
->execute()
->items()
)
->mapWithKeys(fn (Model $model): array => [$model->getKey() => BelongsTo::getSearchLabelWithBlade($model)])
@@ -9,8 +9,8 @@ use App\Contracts\Models\Nameable;
use App\Enums\Http\Api\Filter\ComparisonOperator;
use App\Models\Auth\User;
use App\Models\User\Submission\SubmissionVirtual;
use App\Search\Criteria;
use App\Search\Search;
use App\Scout\Criteria;
use App\Scout\Search;
use Filament\Actions\Action;
use Filament\Schemas\Schema;
use Illuminate\Database\Eloquent\Model;
@@ -89,8 +89,8 @@ class SubmissionBelongsTo extends BelongsTo
return $this
->getSearchResultsUsing(
fn (string $search) => collect(
Search::search($modelClass, new Criteria($this->escapeReservedChars($search)))
->execute()
Search::getSearch($modelClass, new Criteria($this->escapeReservedChars($search)))
->search()
->items()
)
->mapWithKeys(fn (Model $model): array => [$model->getKey() => static::getSearchLabelWithBlade($model, $this->withSubtitle)])
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Filament\Providers;
use App\Filament\Resources\BaseResource;
use App\Search\Criteria;
use App\Search\Search;
use App\Scout\Criteria;
use App\Scout\Search;
use Elastic\ScoutDriverPlus\Searchable;
use Filament\Facades\Filament;
use Filament\GlobalSearch\GlobalSearchResult;
@@ -34,9 +34,8 @@ class GlobalSearchScoutProvider implements GlobalSearchProvider
}
$resourceResults = collect(
Search::search($modelClass, new Criteria($this->escapeReservedChars($query)))
->passToEloquentBuilder(fn (Builder $builder) => $builder->with($resource::getEloquentQuery()->getEagerLoads()))
->execute()
Search::getSearch($modelClass, new Criteria($this->escapeReservedChars($query)))
->search(fn (Builder $builder) => $builder->with($resource::getEloquentQuery()->getEagerLoads()))
->items()
)
->map(function (Model $record) use ($resource): ?GlobalSearchResult {
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Filament\Resources\Base;
use App\Filament\Actions\Base\CreateAction;
use App\Search\Criteria;
use App\Search\Search;
use App\Scout\Criteria;
use App\Scout\Search;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@@ -37,8 +37,11 @@ abstract class BaseListResources extends ListRecords
$model = new $modelClass;
if (filled($search = $this->getTableSearch())) {
$keys = Search::search($modelClass, new Criteria($this->escapeReservedChars($search)))
->keys();
$keys = Search::getSearch($modelClass, new Criteria($this->escapeReservedChars($search)))
->search()
->getCollection()
->map(fn (Model $model) => $model->getKey())
->toArray();
$query
->whereIn($model->getKeyName(), $keys)
@@ -82,7 +82,9 @@ class ThemeResource extends BaseResource
*/
public static function getRecordTitle(?Model $record): ?string
{
return $record?->getName();
return $record instanceof AnimeTheme
? $record->getName()
: null;
}
public static function canGloballySearch(): bool
+9 -6
View File
@@ -26,8 +26,8 @@ use App\Models\Wiki\Series;
use App\Models\Wiki\Song;
use App\Models\Wiki\Studio;
use App\Models\Wiki\Video;
use App\Search\Criteria;
use App\Search\Search;
use App\Scout\Criteria;
use App\Scout\Search;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use Illuminate\Database\Eloquent\Builder;
@@ -131,10 +131,13 @@ class SearchQuery extends BaseQuery
$page = Arr::get($args, 'page', 1);
$first = Number::clamp(Arr::get($args, 'first'), 1, 15);
$searchBuilder = Search::search($modelClass, new Criteria($term))
->passToEloquentBuilder(fn (Builder $builder) => $this->constrainEagerLoads($builder, $resolveInfo, $type, $fieldName))
->withPagination($first, $page);
$searchBuilder = Search::getSearch($modelClass, new Criteria($term))
->search(
fn (Builder $builder) => $this->constrainEagerLoads($builder, $resolveInfo, $type, $fieldName),
$first,
$page,
);
return collect($searchBuilder->execute()->items());
return collect($searchBuilder->items());
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Http\Api\Parser;
use App\Search\Criteria;
use App\Scout\Criteria;
use Illuminate\Support\Arr;
class SearchParser extends Parser
+1 -1
View File
@@ -17,7 +17,7 @@ use App\Http\Api\Parser\IncludeParser;
use App\Http\Api\Parser\PagingParser;
use App\Http\Api\Parser\SearchParser;
use App\Http\Api\Parser\SortParser;
use App\Search\Criteria as SearchCriteria;
use App\Scout\Criteria as SearchCriteria;
use Illuminate\Support\Arr;
class Query
+10 -2
View File
@@ -15,6 +15,8 @@ use App\Models\Auth\User;
use App\Models\BaseModel;
use App\Models\List\External\ExternalEntry;
use App\Models\List\External\ExternalToken;
use App\Scout\Elasticsearch\Models\List\ExternalProfileElasticModel;
use App\Scout\Typesense\Models\List\ExternalProfileTypesenseModel;
use Database\Factories\List\ExternalProfileFactory;
use Elastic\ScoutDriverPlus\Searchable;
use Illuminate\Database\Eloquent\Attributes\Table;
@@ -30,6 +32,7 @@ use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Str;
use Illuminate\Support\Uri;
use RuntimeException;
/**
* @property int $profile_id
@@ -122,11 +125,16 @@ class ExternalProfile extends BaseModel
}
/**
* Only get the attributes as an array to prevent recursive toArray() calls.
* Get the indexable data array for the model.
*/
public function toSearchableArray(): array
{
return $this->attributesToArray();
return match ($driver = Config::get('scout.driver')) {
'collection',
'elastic' => ExternalProfileElasticModel::toSearchableArray($this),
'typesense' => ExternalProfileTypesenseModel::toSearchableArray($this),
default => throw new RuntimeException("Unsupported {$driver} search driver configured."),
};
}
/**
+11 -4
View File
@@ -19,6 +19,8 @@ use App\Models\BaseModel;
use App\Models\List\Playlist\PlaylistTrack;
use App\Models\Wiki\Image;
use App\Pivots\Morph\Imageable;
use App\Scout\Elasticsearch\Models\List\PlaylistElasticModel;
use App\Scout\Typesense\Models\List\PlaylistTypesenseModel;
use Database\Factories\List\PlaylistFactory;
use Elastic\ScoutDriverPlus\Searchable;
use Illuminate\Database\Eloquent\Attributes\Table;
@@ -27,6 +29,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use RuntimeException;
/**
* @property string|null $description
@@ -151,13 +155,16 @@ class Playlist extends BaseModel implements HasAggregateLikes, HasHashids, HasIm
}
/**
* Only get the attributes as an array to prevent recursive toArray() calls.
*
* @return array<string, mixed>
* Get the indexable data array for the model.
*/
public function toSearchableArray(): array
{
return $this->attributesToArray();
return match ($driver = Config::get('scout.driver')) {
'collection',
'elastic' => PlaylistElasticModel::toSearchableArray($this),
'typesense' => PlaylistTypesenseModel::toSearchableArray($this),
default => throw new RuntimeException("Unsupported {$driver} search driver configured."),
};
}
/**
+10 -4
View File
@@ -28,6 +28,8 @@ use App\Pivots\Morph\Imageable;
use App\Pivots\Morph\Resourceable;
use App\Pivots\Wiki\AnimeSeries;
use App\Pivots\Wiki\AnimeStudio;
use App\Scout\Elasticsearch\Models\Wiki\AnimeElasticModel;
use App\Scout\Typesense\Models\Wiki\AnimeTypesenseModel;
use Database\Factories\Wiki\AnimeFactory;
use Deprecated;
use Elastic\ScoutDriverPlus\Searchable;
@@ -40,8 +42,10 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Auditable as HasAudits;
use OwenIt\Auditing\Contracts\Auditable;
use RuntimeException;
/**
* @property int $anime_id
@@ -157,10 +161,12 @@ class Anime extends BaseModel implements Auditable, HasImages, HasResources, Has
*/
public function toSearchableArray(): array
{
$array = $this->attributesToArray();
$array['synonyms'] = $this->synonyms->map(fn (Synonym $synonym) => $synonym->text)->all();
return $array;
return match ($driver = Config::get('scout.driver')) {
'collection',
'elastic' => AnimeElasticModel::toSearchableArray($this),
'typesense' => AnimeTypesenseModel::toSearchableArray($this),
default => throw new RuntimeException("Unsupported {$driver} search driver configured."),
};
}
/**
+17
View File
@@ -10,12 +10,16 @@ use App\Contracts\Models\SoftDeletable;
use App\Enums\Models\Wiki\AnimeSynonymType;
use App\Models\BaseModel;
use App\Models\Wiki\Anime;
use App\Scout\Elasticsearch\Models\Wiki\Anime\AnimeSynonymElasticModel;
use App\Scout\Typesense\Models\Wiki\Anime\AnimeSynonymTypesenseModel;
use Elastic\ScoutDriverPlus\Searchable;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Auditable as HasAudits;
use OwenIt\Auditing\Contracts\Auditable;
use RuntimeException;
/**
* @property Anime $anime
@@ -71,6 +75,19 @@ class AnimeSynonym extends BaseModel implements Auditable, SoftDeletable
];
}
/**
* Get the indexable data array for the model.
*/
public function toSearchableArray(): array
{
return match ($driver = Config::get('scout.driver')) {
'collection',
'elastic' => AnimeSynonymElasticModel::toSearchableArray($this),
'typesense' => AnimeSynonymTypesenseModel::toSearchableArray($this),
default => throw new RuntimeException("Unsupported {$driver} search driver configured."),
};
}
public function getName(): string
{
return $this->text;
+10 -8
View File
@@ -22,6 +22,8 @@ use App\Models\Wiki\Group;
use App\Models\Wiki\Song;
use App\Observers\Wiki\Anime\AnimeThemeObserver;
use App\Scopes\WithoutInsertSongScope;
use App\Scout\Elasticsearch\Models\Wiki\Anime\AnimeThemeElasticModel;
use App\Scout\Typesense\Models\Wiki\Anime\AnimeThemeTypesenseModel;
use Database\Factories\Wiki\Anime\AnimeThemeFactory;
use Elastic\ScoutDriverPlus\Searchable;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
@@ -32,10 +34,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;
use Illuminate\Support\Stringable;
use OwenIt\Auditing\Auditable as HasAudits;
use OwenIt\Auditing\Contracts\Auditable;
use RuntimeException;
/**
* @property Anime $anime
@@ -147,14 +151,12 @@ class AnimeTheme extends BaseModel implements Auditable, InteractsWithSchema, So
*/
public function toSearchableArray(): array
{
$array = $this->attributesToArray();
$array['anime'] = $this->anime->toSearchableArray();
if ($this->song !== null) {
$array['song'] = $this->song->toSearchableArray();
}
return $array;
return match ($driver = Config::get('scout.driver')) {
'collection',
'elastic' => AnimeThemeElasticModel::toSearchableArray($this),
'typesense' => AnimeThemeTypesenseModel::toSearchableArray($this),
default => throw new RuntimeException("Unsupported {$driver} search driver configured."),
};
}
public function getName(): string
@@ -30,6 +30,8 @@ use App\Models\Wiki\Song;
use App\Models\Wiki\Video;
use App\Pivots\Morph\Resourceable;
use App\Pivots\Wiki\AnimeThemeEntryVideo;
use App\Scout\Elasticsearch\Models\Wiki\Anime\Theme\AnimeThemeEntryElasticModel;
use App\Scout\Typesense\Models\Wiki\Anime\Theme\AnimeThemeEntryTypesenseModel;
use Database\Factories\Wiki\Anime\Theme\AnimeThemeEntryFactory;
use Elastic\ScoutDriverPlus\Searchable;
use Illuminate\Database\Eloquent\Attributes\Table;
@@ -40,10 +42,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;
use Illuminate\Support\Stringable;
use OwenIt\Auditing\Auditable as HasAudits;
use OwenIt\Auditing\Contracts\Auditable;
use RuntimeException;
use Znck\Eloquent\Relations\BelongsToThrough;
use Znck\Eloquent\Traits\BelongsToThrough as ZnckBelongsToThrough;
@@ -157,14 +161,12 @@ class AnimeThemeEntry extends BaseModel implements Auditable, HasAggregateLikes,
*/
public function toSearchableArray(): array
{
$array = $this->attributesToArray();
$array['theme'] = $this->animetheme->toSearchableArray();
// Overwrite version with readable format "v{#}"
$array['version'] = Str::of(strval($this->version))->prepend('v')->__toString();
return $array;
return match ($driver = Config::get('scout.driver')) {
'collection',
'elastic' => AnimeThemeEntryElasticModel::toSearchableArray($this),
'typesense' => AnimeThemeEntryTypesenseModel::toSearchableArray($this),
default => throw new RuntimeException("Unsupported {$driver} search driver configured."),
};
}
public function getName(): string
+10 -13
View File
@@ -24,6 +24,8 @@ use App\Pivots\Morph\Imageable;
use App\Pivots\Morph\Resourceable;
use App\Pivots\Wiki\ArtistMember;
use App\Pivots\Wiki\ArtistSong;
use App\Scout\Elasticsearch\Models\Wiki\ArtistElasticModel;
use App\Scout\Typesense\Models\Wiki\ArtistTypesenseModel;
use Database\Factories\Wiki\ArtistFactory;
use Deprecated;
use Elastic\ScoutDriverPlus\Searchable;
@@ -36,8 +38,10 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Auditable as HasAudits;
use OwenIt\Auditing\Contracts\Auditable;
use RuntimeException;
use Staudenmeir\EloquentHasManyDeep\HasManyDeep;
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
@@ -149,19 +153,12 @@ class Artist extends BaseModel implements Auditable, HasImages, HasResources, Ha
*/
public function toSearchableArray(): array
{
$array = $this->attributesToArray();
$array['as'] = $this->performances->map(fn (Performance $performance) => $performance->as)
->toBase()
->concat($this->memberships->map(fn (Membership $membership) => $membership->as))
->filter()
->unique()
->values()
->all();
$array['synonyms'] = $this->synonyms->map(fn (Synonym $synonym) => $synonym->text)->all();
return $array;
return match ($driver = Config::get('scout.driver')) {
'collection',
'elastic' => ArtistElasticModel::toSearchableArray($this),
'typesense' => ArtistTypesenseModel::toSearchableArray($this),
default => throw new RuntimeException("Unsupported {$driver} search driver configured."),
};
}
/**
+10 -6
View File
@@ -14,6 +14,8 @@ use App\Events\Wiki\Series\SeriesUpdated;
use App\Http\Resources\Pivot\Wiki\Resource\AnimeSeriesJsonResource;
use App\Models\BaseModel;
use App\Pivots\Wiki\AnimeSeries;
use App\Scout\Elasticsearch\Models\Wiki\SeriesElasticModel;
use App\Scout\Typesense\Models\Wiki\SeriesTypesenseModel;
use Database\Factories\Wiki\SeriesFactory;
use Elastic\ScoutDriverPlus\Searchable;
use Illuminate\Database\Eloquent\Attributes\Table;
@@ -21,8 +23,10 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Auditable as HasAudits;
use OwenIt\Auditing\Contracts\Auditable;
use RuntimeException;
/**
* @property Collection<int, Anime> $anime
@@ -100,12 +104,12 @@ class Series extends BaseModel implements Auditable, SoftDeletable
*/
public function toSearchableArray(): array
{
$array = $this->attributesToArray();
$array['anime'] = $this->anime->map(
fn (Anime $anime): array => $anime->toSearchableArray()
)->toArray();
return $array;
return match ($driver = Config::get('scout.driver')) {
'collection',
'elastic' => SeriesElasticModel::toSearchableArray($this),
'typesense' => SeriesTypesenseModel::toSearchableArray($this),
default => throw new RuntimeException("Unsupported {$driver} search driver configured."),
};
}
/**
+17
View File
@@ -19,6 +19,8 @@ use App\Models\Wiki\Anime\AnimeTheme;
use App\Models\Wiki\Song\Performance;
use App\Pivots\Morph\Resourceable;
use App\Pivots\Wiki\ArtistSong;
use App\Scout\Elasticsearch\Models\Wiki\SongElasticModel;
use App\Scout\Typesense\Models\Wiki\SongTypesenseModel;
use Database\Factories\Wiki\SongFactory;
use Deprecated;
use Elastic\ScoutDriverPlus\Searchable;
@@ -28,8 +30,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Auditable as HasAudits;
use OwenIt\Auditing\Contracts\Auditable;
use RuntimeException;
/**
* @property Collection<int, AnimeTheme> $animethemes
@@ -104,6 +108,19 @@ class Song extends BaseModel implements Auditable, HasResources, SoftDeletable
];
}
/**
* Get the indexable data array for the model.
*/
public function toSearchableArray(): array
{
return match ($driver = Config::get('scout.driver')) {
'collection',
'elastic' => SongElasticModel::toSearchableArray($this),
'typesense' => SongTypesenseModel::toSearchableArray($this),
default => throw new RuntimeException("Unsupported {$driver} search driver configured."),
};
}
public function getName(): string
{
if (blank($this->title)) {
+17
View File
@@ -18,6 +18,8 @@ use App\Models\BaseModel;
use App\Pivots\Morph\Imageable;
use App\Pivots\Morph\Resourceable;
use App\Pivots\Wiki\AnimeStudio;
use App\Scout\Elasticsearch\Models\Wiki\StudioElasticModel;
use App\Scout\Typesense\Models\Wiki\StudioTypesenseModel;
use Database\Factories\Wiki\StudioFactory;
use Elastic\ScoutDriverPlus\Searchable;
use Illuminate\Database\Eloquent\Attributes\Table;
@@ -25,8 +27,10 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Auditable as HasAudits;
use OwenIt\Auditing\Contracts\Auditable;
use RuntimeException;
/**
* @property Collection<int, Anime> $anime
@@ -93,6 +97,19 @@ class Studio extends BaseModel implements Auditable, HasImages, HasResources, So
];
}
/**
* Get the indexable data array for the model.
*/
public function toSearchableArray(): array
{
return match ($driver = Config::get('scout.driver')) {
'collection',
'elastic' => StudioElasticModel::toSearchableArray($this),
'typesense' => StudioTypesenseModel::toSearchableArray($this),
default => throw new RuntimeException("Unsupported {$driver} search driver configured."),
};
}
/**
* Get the route key for the model.
*
+17
View File
@@ -15,14 +15,18 @@ use App\Events\Wiki\Synonym\SynonymDeleted;
use App\Events\Wiki\Synonym\SynonymRestored;
use App\Events\Wiki\Synonym\SynonymUpdated;
use App\Models\BaseModel;
use App\Scout\Elasticsearch\Models\Wiki\SynonymElasticModel;
use App\Scout\Typesense\Models\Wiki\SynonymTypesenseModel;
use Database\Factories\Wiki\SynonymFactory;
use Elastic\ScoutDriverPlus\Searchable;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Auditable as HasAudits;
use OwenIt\Auditing\Contracts\Auditable;
use RuntimeException;
/**
* @property int $synonym_id
@@ -94,6 +98,19 @@ class Synonym extends BaseModel implements Auditable, SoftDeletable
];
}
/**
* Get the indexable data array for the model.
*/
public function toSearchableArray(): array
{
return match ($driver = Config::get('scout.driver')) {
'collection',
'elastic' => SynonymElasticModel::toSearchableArray($this),
'typesense' => SynonymTypesenseModel::toSearchableArray($this),
default => throw new RuntimeException("Unsupported {$driver} search driver configured."),
};
}
public function getName(): string
{
return $this->text;
+10 -9
View File
@@ -23,6 +23,8 @@ use App\Models\List\Playlist\PlaylistTrack;
use App\Models\Wiki\Anime\Theme\AnimeThemeEntry;
use App\Models\Wiki\Video\VideoScript;
use App\Pivots\Wiki\AnimeThemeEntryVideo;
use App\Scout\Elasticsearch\Models\Wiki\VideoElasticModel;
use App\Scout\Typesense\Models\Wiki\VideoTypesenseModel;
use Database\Factories\Wiki\VideoFactory;
use Elastic\ScoutDriverPlus\Searchable;
use Illuminate\Database\Eloquent\Attributes\Appends;
@@ -35,8 +37,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Auditable as HasAudits;
use OwenIt\Auditing\Contracts\Auditable;
use RuntimeException;
/**
* @property Collection<int, AnimeThemeEntry> $animethemeentries
@@ -247,18 +251,15 @@ class Video extends BaseModel implements Auditable, SoftDeletable, Streamable
/**
* Get the indexable data array for the model.
*
* @return array<string, mixed>
*/
public function toSearchableArray(): array
{
$array = $this->attributesToArray();
$array['entries'] = $this->animethemeentries->map(
fn (AnimeThemeEntry $entry): array => $entry->toSearchableArray()
)->toArray();
return $array;
return match ($driver = Config::get('scout.driver')) {
'collection',
'elastic' => VideoElasticModel::toSearchableArray($this),
'typesense' => VideoTypesenseModel::toSearchableArray($this),
default => throw new RuntimeException("Unsupported {$driver} search driver configured."),
};
}
/**
+2 -1
View File
@@ -43,6 +43,7 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\ParallelTesting;
use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;
use Psr\Http\Message\MessageInterface;
class AppServiceProvider extends ServiceProvider
{
@@ -60,7 +61,7 @@ class AppServiceProvider extends ServiceProvider
Artisan::call('db:seed', ['--class' => ProhibitionSeeder::class]);
});
Http::globalRequestMiddleware(fn (Request $request) => $request->withHeader('User-Agent', 'AnimeThemes'));
Http::globalRequestMiddleware(fn (Request $request): MessageInterface => $request->withHeader('User-Agent', 'AnimeThemes'));
DB::listen(function (QueryExecuted $query): void {
if (app()->isLocal()) {
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Scout\Collection;
use App\Enums\Http\Api\Paging\PaginationStrategy;
use App\Http\Api\Query\Query;
use App\Http\Api\Schema\EloquentSchema;
use App\Scout\Criteria;
use App\Scout\Search;
use Closure;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class CollectionSearch extends Search
{
public function __construct(protected Model $model, Criteria $criteria)
{
parent::__construct($criteria);
}
/**
* Perform the search.
*/
public function searchViaJSONAPI(
Query $query,
EloquentSchema $schema,
PaginationStrategy $paginationStrategy
): Collection|Paginator {
// TODO
return $this->search();
}
/**
* @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
{
/** @phpstan-ignore-next-line */
return $this->model::search($this->criteria->getTerm())
->query($callback)
->paginate($perPage, page: $page);
}
/**
* Should the search be performed?
*/
public function shouldSearch(): bool
{
return true;
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Search;
namespace App\Scout;
readonly class Criteria
{
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Scout\Elasticsearch\Api\Query;
use App\Search\Criteria;
use App\Scout\Criteria;
use Elastic\ScoutDriverPlus\Builders\SearchParametersBuilder;
abstract class ElasticQuery
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Scout\Elasticsearch\Api\Query\List;
use App\Models\List\Playlist;
use App\Scout\Criteria;
use App\Scout\Elasticsearch\Api\Query\ElasticQuery;
use App\Search\Criteria;
use Elastic\ScoutDriverPlus\Builders\SearchParametersBuilder;
use Elastic\ScoutDriverPlus\Support\Query;
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Scout\Elasticsearch\Api\Query\Wiki\Anime;
use App\Models\Wiki\Anime\AnimeSynonym;
use App\Scout\Criteria;
use App\Scout\Elasticsearch\Api\Query\ElasticQuery;
use App\Search\Criteria;
use Elastic\ScoutDriverPlus\Builders\SearchParametersBuilder;
use Elastic\ScoutDriverPlus\Support\Query;
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Scout\Elasticsearch\Api\Query\Wiki\Anime\Theme;
use App\Models\Wiki\Anime\Theme\AnimeThemeEntry;
use App\Scout\Criteria;
use App\Scout\Elasticsearch\Api\Query\ElasticQuery;
use App\Search\Criteria;
use Elastic\ScoutDriverPlus\Builders\SearchParametersBuilder;
use Elastic\ScoutDriverPlus\Support\Query;
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Scout\Elasticsearch\Api\Query\Wiki\Anime;
use App\Models\Wiki\Anime\AnimeTheme;
use App\Scout\Criteria;
use App\Scout\Elasticsearch\Api\Query\ElasticQuery;
use App\Search\Criteria;
use Elastic\ScoutDriverPlus\Builders\SearchParametersBuilder;
use Elastic\ScoutDriverPlus\Support\Query;
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Scout\Elasticsearch\Api\Query\Wiki;
use App\Models\Wiki\Anime;
use App\Scout\Criteria;
use App\Scout\Elasticsearch\Api\Query\ElasticQuery;
use App\Search\Criteria;
use Elastic\ScoutDriverPlus\Builders\SearchParametersBuilder;
use Elastic\ScoutDriverPlus\Support\Query;
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Scout\Elasticsearch\Api\Query\Wiki;
use App\Models\Wiki\Artist;
use App\Scout\Criteria;
use App\Scout\Elasticsearch\Api\Query\ElasticQuery;
use App\Search\Criteria;
use Elastic\ScoutDriverPlus\Builders\SearchParametersBuilder;
use Elastic\ScoutDriverPlus\Support\Query;
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Scout\Elasticsearch\Api\Query\Wiki;
use App\Models\Wiki\Series;
use App\Scout\Criteria;
use App\Scout\Elasticsearch\Api\Query\ElasticQuery;
use App\Search\Criteria;
use Elastic\ScoutDriverPlus\Builders\SearchParametersBuilder;
use Elastic\ScoutDriverPlus\Support\Query;
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Scout\Elasticsearch\Api\Query\Wiki;
use App\Models\Wiki\Song;
use App\Scout\Criteria;
use App\Scout\Elasticsearch\Api\Query\ElasticQuery;
use App\Search\Criteria;
use Elastic\ScoutDriverPlus\Builders\SearchParametersBuilder;
use Elastic\ScoutDriverPlus\Support\Query;
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Scout\Elasticsearch\Api\Query\Wiki;
use App\Models\Wiki\Studio;
use App\Scout\Criteria;
use App\Scout\Elasticsearch\Api\Query\ElasticQuery;
use App\Search\Criteria;
use Elastic\ScoutDriverPlus\Builders\SearchParametersBuilder;
use Elastic\ScoutDriverPlus\Support\Query;
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Scout\Elasticsearch\Api\Query\Wiki;
use App\Models\Wiki\Synonym;
use App\Scout\Criteria;
use App\Scout\Elasticsearch\Api\Query\ElasticQuery;
use App\Search\Criteria;
use Elastic\ScoutDriverPlus\Builders\SearchParametersBuilder;
use Elastic\ScoutDriverPlus\Support\Query;
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Scout\Elasticsearch\Api\Query\Wiki;
use App\Models\Wiki\Video;
use App\Scout\Criteria;
use App\Scout\Elasticsearch\Api\Query\ElasticQuery;
use App\Search\Criteria;
use Elastic\ScoutDriverPlus\Builders\SearchParametersBuilder;
use Elastic\ScoutDriverPlus\Support\Query;
+106 -16
View File
@@ -10,36 +10,68 @@ 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\AnimeSynonym;
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\SynonymQuery as AnimeSynonymQuery;
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 App\Search\Drivers\ElasticsearchDriver;
use App\Search\Search as SearchSearch;
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;
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)
public function __construct(ClientBuilderInterface $builder, SearchCriteria $criteria)
{
parent::__construct($criteria);
try {
$this->alive = $builder->default()->ping()->asBool();
} catch (Exception $exception) {
@@ -59,15 +91,15 @@ class Elasticsearch extends Search
/**
* Should the search be performed?
*/
public function shouldSearch(Query $query): bool
public function shouldSearch(): bool
{
return $query->hasSearchCriteria() && $this->isAlive();
return $this->isAlive();
}
/**
* Perform the search.
*/
public function search(
public function searchViaJSONAPI(
Query $query,
EloquentSchema $schema,
PaginationStrategy $paginationStrategy
@@ -75,17 +107,16 @@ class Elasticsearch extends Search
$elasticSchema = $this->elasticSchema($schema);
// initialize builder for matches
/** @var ElasticsearchDriver $searchBuilder */
$searchBuilder = SearchSearch::search($elasticSchema->model(), $query->getSearchCriteria());
$builder = $searchBuilder->builder;
$searchBuilder = static::elasticQuery($elasticSchema->model()::class)->build($this->criteria);
$this->elasticBuilder = $searchBuilder;
// load aggregate fields
$builder->refineModels(function (Builder $searchModelBuilder) use ($query, $schema): void {
$this->elasticBuilder->refineModels(function (EloquentBuilder $searchModelBuilder) use ($query, $schema): void {
$this->withAggregates($searchModelBuilder, $query, $schema);
});
// eager load relations with constraints
$builder = $builder->load($this->constrainEagerLoads($query, $schema));
$this->elasticBuilder = $this->elasticBuilder->load($this->constrainEagerLoads($query, $schema));
// apply filters
$filterBuilder = new BoolQueryBuilder();
@@ -101,7 +132,7 @@ class Elasticsearch extends Search
}
}
try {
$builder->postFilter($filterBuilder);
$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
}
@@ -119,7 +150,7 @@ class Elasticsearch extends Search
}
}
if ($sorts !== []) {
$builder->sortRaw($sorts);
$this->elasticBuilder->sortRaw($sorts);
}
// paginate
@@ -127,8 +158,67 @@ class Elasticsearch extends Search
$elasticPaginationCriteria = PagingParser::parse($paginationCriteria);
return $elasticPaginationCriteria instanceof Api\Criteria\Paging\Criteria
? $elasticPaginationCriteria->paginate($builder)
: $builder->execute()->models();
? $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
{
// Resolve callback.
$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(),
AnimeSynonym::class => new AnimeSynonymQuery(),
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}"),
};
}
/**
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Scout\Elasticsearch\Models\List;
use App\Models\List\ExternalProfile;
class ExternalProfileElasticModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(ExternalProfile $profile): array
{
return $profile->attributesToArray();
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Scout\Elasticsearch\Models\List;
use App\Models\List\Playlist;
class PlaylistElasticModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(Playlist $playlist): array
{
return $playlist->attributesToArray();
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Scout\Elasticsearch\Models\Wiki\Anime;
use App\Models\Wiki\Anime\AnimeSynonym;
class AnimeSynonymElasticModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(AnimeSynonym $synonym): array
{
return $synonym->attributesToArray();
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Scout\Elasticsearch\Models\Wiki\Anime;
use App\Models\Wiki\Anime\AnimeTheme;
class AnimeThemeElasticModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(AnimeTheme $theme): array
{
$array = [
...$theme->attributesToArray(),
'anime' => $theme->anime->attributesToArray(),
];
if ($theme->song !== null) {
$array['song'] = $theme->song->toSearchableArray();
}
return $array;
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Scout\Elasticsearch\Models\Wiki\Anime\Theme;
use App\Models\Wiki\Anime\Theme\AnimeThemeEntry;
use Illuminate\Support\Str;
class AnimeThemeEntryElasticModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(AnimeThemeEntry $entry): array
{
return [
...$entry->attributesToArray(),
'theme' => $entry->animetheme->toSearchableArray(),
'version' => Str::of(strval($entry->version))->prepend('v')->__toString(),
];
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Scout\Elasticsearch\Models\Wiki;
use App\Models\Wiki\Anime;
use App\Models\Wiki\Synonym;
class AnimeElasticModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(Anime $anime): array
{
return [
...$anime->attributesToArray(),
'synonyms' => $anime->synonyms->map(fn (Synonym $synonym) => $synonym->text)->all(),
];
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Scout\Elasticsearch\Models\Wiki;
use App\Models\Wiki\Artist;
use App\Models\Wiki\Song\Membership;
use App\Models\Wiki\Song\Performance;
use App\Models\Wiki\Synonym;
class ArtistElasticModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(Artist $artist): array
{
return [
...$artist->attributesToArray(),
'synonyms' => $artist->synonyms->map(fn (Synonym $synonym) => $synonym->text)->all(),
'as' => $artist->performances->map(fn (Performance $performance) => $performance->as)
->toBase()
->concat($artist->memberships->map(fn (Membership $membership) => $membership->as))
->filter()
->unique()
->values()
->all(),
];
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Scout\Elasticsearch\Models\Wiki;
use App\Models\Wiki\Anime;
use App\Models\Wiki\Series;
class SeriesElasticModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(Series $series): array
{
return [
...$series->attributesToArray(),
'anime' => $series->anime->map(
fn (Anime $anime): array => $anime->toSearchableArray()
)->all(),
];
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Scout\Elasticsearch\Models\Wiki;
use App\Models\Wiki\Song;
class SongElasticModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(Song $song): array
{
return $song->attributesToArray();
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Scout\Elasticsearch\Models\Wiki;
use App\Models\Wiki\Studio;
class StudioElasticModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(Studio $studio): array
{
return $studio->attributesToArray();
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Scout\Elasticsearch\Models\Wiki;
use App\Models\Wiki\Synonym;
class SynonymElasticModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(Synonym $synonym): array
{
return $synonym->attributesToArray();
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Scout\Elasticsearch\Models\Wiki;
use App\Models\Wiki\Anime\Theme\AnimeThemeEntry;
use App\Models\Wiki\Video;
class VideoElasticModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(Video $video): array
{
return [
...$video->attributesToArray(),
'entries' => $video->animethemeentries->map(
fn (AnimeThemeEntry $entry): array => $entry->toSearchableArray()
)->all(),
];
}
}
+35 -2
View File
@@ -7,22 +7,55 @@ namespace App\Scout;
use App\Enums\Http\Api\Paging\PaginationStrategy;
use App\Http\Api\Query\Query;
use App\Http\Api\Schema\EloquentSchema;
use App\Scout\Collection\CollectionSearch;
use App\Scout\Elasticsearch\Elasticsearch;
use App\Scout\Typesense\Typesense;
use Closure;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Config;
use RuntimeException;
abstract class Search
{
public function __construct(protected Criteria $criteria) {}
/**
* @param class-string<Model>|Model $model
*/
public static function getSearch(Model|string $model, Criteria $criteria): Search
{
$model = $model instanceof Model ? $model : new $model;
return match ($driver = Config::get('scout.driver')) {
'collection' => App::make(CollectionSearch::class, ['model' => $model, 'criteria' => $criteria]),
'elastic' => App::make(Elasticsearch::class, ['criteria' => $criteria]),
'typesense' => App::make(Typesense::class, ['model' => $model, 'criteria' => $criteria]),
default => throw new RuntimeException("Unsupported {$driver} search driver configured."),
};
}
/**
* Should the search be performed?
*/
abstract public function shouldSearch(Query $query): bool;
abstract public function shouldSearch(): bool;
/**
* Perform the search.
*/
abstract public function search(
abstract public function searchViaJSONAPI(
Query $query,
EloquentSchema $schema,
PaginationStrategy $paginationStrategy
): Collection|Paginator;
/**
* @param Closure(EloquentBuilder): mixed $callback
* @param array<string, array{direction: string, isString: bool, relation: ?string}> $sorts
*/
abstract public function search(?Closure $callback = null, int $perPage = 15, int $page = 1, array $sorts = []): LengthAwarePaginator;
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scout\Typesense\Models\List;
use App\Models\List\ExternalProfile;
class ExternalProfileTypesenseModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(ExternalProfile $profile): array
{
return [
...$profile->attributesToArray(),
'id' => (string) $profile->getKey(),
];
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scout\Typesense\Models\List;
use App\Models\List\Playlist;
class PlaylistTypesenseModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(Playlist $playlist): array
{
return [
...$playlist->attributesToArray(),
'id' => (string) $playlist->getKey(),
];
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Scout\Typesense\Models\Wiki\Anime;
use App\Models\Wiki\Anime\AnimeSynonym;
class AnimeSynonymTypesenseModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(AnimeSynonym $synonym): array
{
return [
...$synonym->attributesToArray(),
'id' => (string) $synonym->getKey(),
'created_at' => $synonym->created_at?->timestamp,
'updated_at' => $synonym->updated_at?->timestamp,
'deleted_at' => $synonym->deleted_at?->timestamp,
];
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Scout\Typesense\Models\Wiki\Anime;
use App\Models\Wiki\Anime\AnimeTheme;
class AnimeThemeTypesenseModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(AnimeTheme $theme): array
{
return [
'id' => (string) $theme->getKey(),
'created_at' => $theme->created_at?->timestamp,
'type_sequence' => $theme->type->localize().($theme->sequence ?? 1),
'type' => $theme->type->localize(),
'sequence' => (string) ($theme->sequence ?? 1),
'anime' => $theme->anime->toSearchableArray(),
'song' => $theme->song?->toSearchableArray(),
];
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Scout\Typesense\Models\Wiki\Anime\Theme;
use App\Models\Wiki\Anime\Theme\AnimeThemeEntry;
use Illuminate\Support\Str;
class AnimeThemeEntryTypesenseModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(AnimeThemeEntry $entry): array
{
return [
...$entry->attributesToArray(),
'id' => (string) $entry->getKey(),
'created_at' => $entry->created_at?->timestamp,
'animetheme' => $entry->animetheme->toSearchableArray(),
'version' => $version = Str::of(strval($entry->version))->prepend('v')->__toString(),
'type_sequence_version' => $entry->animetheme->type->localize().(($entry->animetheme->sequence ?? 1)).$version,
];
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Scout\Typesense\Models\Wiki;
use App\Models\Wiki\Anime;
use App\Models\Wiki\Synonym;
class AnimeTypesenseModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(Anime $anime): array
{
return [
...$anime->attributesToArray(),
'id' => (string) $anime->getKey(),
'season' => $anime->season?->localize(),
'created_at' => $anime->created_at?->timestamp,
'updated_at' => $anime->updated_at?->timestamp,
'deleted_at' => $anime->deleted_at?->timestamp,
'synonyms' => $anime->synonyms->map(fn (Synonym $synonym) => $synonym->text)->all(),
];
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Scout\Typesense\Models\Wiki;
use App\Models\Wiki\Artist;
use App\Models\Wiki\Song\Membership;
use App\Models\Wiki\Song\Performance;
use App\Models\Wiki\Synonym;
class ArtistTypesenseModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(Artist $artist): array
{
return [
'id' => (string) $artist->getKey(),
'name' => $artist->name,
'created_at' => $artist->created_at?->timestamp,
'updated_at' => $artist->updated_at?->timestamp,
'deleted_at' => $artist->deleted_at?->timestamp,
'synonyms' => $synonyms = $artist->synonyms->map(fn (Synonym $synonym) => $synonym->text)->all(),
'as' => $as = $artist->performances->map(fn (Performance $performance) => $performance->as)
->toBase()
->concat($artist->memberships->map(fn (Membership $membership) => $membership->as))
->filter()
->unique()
->values()
->all(),
'search_text' => implode(' ', [
$artist->name,
...$synonyms,
// ...$as,
]),
];
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Scout\Typesense\Models\Wiki;
use App\Models\Wiki\Anime;
use App\Models\Wiki\Series;
class SeriesTypesenseModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(Series $series): array
{
return [
...$series->attributesToArray(),
'id' => (string) $series->getKey(),
'created_at' => $series->created_at?->timestamp,
'anime' => $series->anime->map(
fn (Anime $anime): array => $anime->toSearchableArray()
)->all(),
];
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Scout\Typesense\Models\Wiki;
use App\Models\Wiki\Song;
class SongTypesenseModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(Song $song): array
{
return [
...$song->attributesToArray(),
'id' => (string) $song->getKey(),
'created_at' => $song->created_at?->timestamp,
'updated_at' => $song->updated_at?->timestamp,
'deleted_at' => $song->deleted_at?->timestamp,
];
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Scout\Typesense\Models\Wiki;
use App\Models\Wiki\Studio;
class StudioTypesenseModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(Studio $studio): array
{
return [
...$studio->attributesToArray(),
'id' => (string) $studio->getKey(),
'created_at' => $studio->created_at?->timestamp,
];
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Scout\Typesense\Models\Wiki;
use App\Models\Wiki\Synonym;
class SynonymTypesenseModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(Synonym $synonym): array
{
return [
...$synonym->attributesToArray(),
'id' => (string) $synonym->getKey(),
'created_at' => $synonym->created_at?->timestamp,
];
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Scout\Typesense\Models\Wiki;
use App\Models\Wiki\Anime\Theme\AnimeThemeEntry;
use App\Models\Wiki\Video;
class VideoTypesenseModel
{
/**
* @return array<string, mixed>
*/
public static function toSearchableArray(Video $video): array
{
return [
...$video->attributesToArray(),
'id' => (string) $video->getKey(),
'created_at' => $video->created_at?->timestamp,
'updated_at' => $video->updated_at?->timestamp,
'deleted_at' => $video->deleted_at?->timestamp,
'entries' => $video->animethemeentries->map(
fn (AnimeThemeEntry $entry): array => $entry->toSearchableArray()
)->all(),
];
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Scout\Typesense;
use App\Enums\Http\Api\Paging\PaginationStrategy;
use App\Http\Api\Query\Query;
use App\Http\Api\Schema\EloquentSchema;
use App\Scout\Criteria;
use App\Scout\Search;
use Closure;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class Typesense extends Search
{
public function __construct(protected Model $model, Criteria $criteria)
{
parent::__construct($criteria);
}
/**
* Perform the search.
*/
public function searchViaJSONAPI(
Query $query,
EloquentSchema $schema,
PaginationStrategy $paginationStrategy
): Collection|Paginator {
// TODO
return $this->search();
}
/**
* @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
{
/** @phpstan-ignore-next-line */
return $this->model::search($this->criteria->getTerm())
->query($callback)
->paginate($perPage, page: $page);
}
/**
* Should the search be performed?
*/
public function shouldSearch(): bool
{
return true;
}
}
-85
View File
@@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Search\Drivers;
use App\Contracts\Search\SearchDriver;
use App\Search\Criteria;
use Closure;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Laravel\Scout\Builder;
class CollectionDriver implements SearchDriver
{
protected Builder $builder;
protected int $perPage = 15;
protected int $page = 1;
protected function __construct(protected Model $model) {}
public static function search(Model $model, Criteria $criteria): SearchDriver
{
$builder = new self($model);
/** @phpstan-ignore-next-line */
$builder->builder = $model::search($criteria->getTerm());
return $builder;
}
public function withPagination(int $perPage = 15, int $page = 1): static
{
$this->perPage = $perPage;
$this->page = $page;
return $this;
}
/**
* @param array<string, array{direction: string, isString: bool, relation: ?string}> $sorts
*/
public function withSort(array $sorts): static
{
foreach ($sorts as $column => $data) {
$this->builder->orderBy($column, Arr::get($data, 'direction'));
}
return $this;
}
/**
* Run a callback through the Eloquent query.
*
* @param Closure(EloquentBuilder): void $callback
*/
public function passToEloquentBuilder(Closure $callback): SearchDriver
{
$this->builder->query($callback);
return $this;
}
/**
* Execute the search and get the resulting models.
*/
public function execute(): Paginator
{
return $this->builder
->paginate($this->perPage, page: $this->page);
}
/**
* Get the keys of the retrieved models.
*
* @return int[]
*/
public function keys(): array
{
return Arr::pluck($this->execute()->items(), fn (Model $model) => $model->getKey());
}
}
-154
View File
@@ -1,154 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Search\Drivers;
use App\Contracts\Search\SearchDriver;
use App\Models\List\Playlist;
use App\Models\Wiki\Anime;
use App\Models\Wiki\Anime\AnimeSynonym;
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\Elasticsearch\Api\Query\ElasticQuery;
use App\Scout\Elasticsearch\Api\Query\List\PlaylistQuery;
use App\Scout\Elasticsearch\Api\Query\Wiki\Anime\SynonymQuery as AnimeSynonymQuery;
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\Search\Criteria;
use Closure;
use Elastic\ScoutDriverPlus\Builders\SearchParametersBuilder;
use Elastic\ScoutDriverPlus\Paginator;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use RuntimeException;
class ElasticsearchDriver implements SearchDriver
{
public SearchParametersBuilder $builder;
protected int $perPage = 15;
protected int $page = 1;
protected function __construct(protected Model $model) {}
public static function search(Model $model, Criteria $criteria): SearchDriver
{
$builder = new self($model);
$builder->builder = static::elasticQuery($model::class)
->build($criteria);
return $builder;
}
public function withPagination(int $perPage, int $page = 1): static
{
$this->perPage = $perPage;
$this->page = $page;
return $this;
}
/**
* @param array<string, array{direction: string, isString: bool, relation: ?string}> $sorts
*/
public function withSort(array $sorts): static
{
$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->builder->sortRaw($sortRaw);
}
return $this;
}
/**
* Run a callback through the Eloquent query.
*
* @param Closure(EloquentBuilder): void $callback
*/
public function passToEloquentBuilder(Closure $callback): static
{
$this->builder->refineModels($callback);
return $this;
}
/**
* Execute the search and get the resulting models.
*/
public function execute(): Paginator
{
return $this->builder->paginate($this->perPage, page: $this->page)->onlyModels();
}
/**
* Get the keys of the retrieved models.
*
* @return int[]
*/
public function keys(): array
{
return $this
->execute()
->getCollection()
->map(fn (Model $model) => $model->getKey())
->toArray();
}
/**
* @param class-string<Model> $model
*/
public static function elasticQuery(string $model): ElasticQuery
{
return match ($model) {
Playlist::class => new PlaylistQuery(),
AnimeThemeEntry::class => new EntryQuery(),
AnimeSynonym::class => new AnimeSynonymQuery(),
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}"),
};
}
}
-29
View File
@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Search;
use App\Contracts\Search\SearchDriver;
use App\Search\Drivers\CollectionDriver;
use App\Search\Drivers\ElasticsearchDriver;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
use RuntimeException;
class Search
{
/**
* @param class-string<Model>|Model $model
*/
public static function search(Model|string $model, Criteria $criteria): SearchDriver
{
$model = $model instanceof Model ? $model : new $model;
return match ($driver = Config::get('scout.driver')) {
'elastic' => ElasticsearchDriver::search($model, $criteria),
'collection' => CollectionDriver::search($model, $criteria),
default => throw new RuntimeException("Unsupported {$driver} search driver configured."),
};
}
}
+1
View File
@@ -59,6 +59,7 @@
"staudenmeir/laravel-adjacency-list": "^1.26.0",
"symfony/http-client": "^6.4.34",
"symfony/mailgun-mailer": "^6.4.24",
"typesense/typesense-php": "^6.0",
"vinkla/hashids": "^14.0"
},
"require-dev": {
Generated
+345 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "a746bf2d1aad42b8f36655341a25a2ef",
"content-hash": "e7e00007562758a8482a3967c7bfbf5c",
"packages": [
{
"name": "ajcastro/eager-load-pivot-relations",
@@ -1221,6 +1221,72 @@
],
"time": "2026-03-20T21:10:52+00:00"
},
{
"name": "clue/stream-filter",
"version": "v1.7.0",
"source": {
"type": "git",
"url": "https://github.com/clue/stream-filter.git",
"reference": "049509fef80032cb3f051595029ab75b49a3c2f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/clue/stream-filter/zipball/049509fef80032cb3f051595029ab75b49a3c2f7",
"reference": "049509fef80032cb3f051595029ab75b49a3c2f7",
"shasum": ""
},
"require": {
"php": ">=5.3"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"type": "library",
"autoload": {
"files": [
"src/functions_include.php"
],
"psr-4": {
"Clue\\StreamFilter\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering"
}
],
"description": "A simple and modern approach to stream filtering in PHP",
"homepage": "https://github.com/clue/stream-filter",
"keywords": [
"bucket brigade",
"callback",
"filter",
"php_user_filter",
"stream",
"stream_filter_append",
"stream_filter_register"
],
"support": {
"issues": "https://github.com/clue/stream-filter/issues",
"source": "https://github.com/clue/stream-filter/tree/v1.7.0"
},
"funding": [
{
"url": "https://clue.engineering/support",
"type": "custom"
},
{
"url": "https://github.com/clue",
"type": "github"
}
],
"time": "2023-12-20T15:40:13+00:00"
},
{
"name": "danharrin/date-format-converter",
"version": "v0.3.1",
@@ -6637,6 +6703,73 @@
},
"time": "2025-09-24T15:06:41+00:00"
},
{
"name": "php-http/client-common",
"version": "2.7.3",
"source": {
"type": "git",
"url": "https://github.com/php-http/client-common.git",
"reference": "dcc6de29c90dd74faab55f71b79d89409c4bf0c1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/client-common/zipball/dcc6de29c90dd74faab55f71b79d89409c4bf0c1",
"reference": "dcc6de29c90dd74faab55f71b79d89409c4bf0c1",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0",
"php-http/httplug": "^2.0",
"php-http/message": "^1.6",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.0 || ^2.0",
"symfony/options-resolver": "~4.0.15 || ~4.1.9 || ^4.2.1 || ^5.0 || ^6.0 || ^7.0 || ^8.0",
"symfony/polyfill-php80": "^1.17"
},
"require-dev": {
"doctrine/instantiator": "^1.1",
"guzzlehttp/psr7": "^1.4",
"nyholm/psr7": "^1.2",
"phpunit/phpunit": "^7.5.20 || ^8.5.33 || ^9.6.7"
},
"suggest": {
"ext-json": "To detect JSON responses with the ContentTypePlugin",
"ext-libxml": "To detect XML responses with the ContentTypePlugin",
"php-http/cache-plugin": "PSR-6 Cache plugin",
"php-http/logger-plugin": "PSR-3 Logger plugin",
"php-http/stopwatch-plugin": "Symfony Stopwatch plugin"
},
"type": "library",
"autoload": {
"psr-4": {
"Http\\Client\\Common\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"description": "Common HTTP Client implementations and tools for HTTPlug",
"homepage": "http://httplug.io",
"keywords": [
"client",
"common",
"http",
"httplug"
],
"support": {
"issues": "https://github.com/php-http/client-common/issues",
"source": "https://github.com/php-http/client-common/tree/2.7.3"
},
"time": "2025-11-29T19:12:34+00:00"
},
{
"name": "php-http/discovery",
"version": "1.20.0",
@@ -6773,6 +6906,75 @@
},
"time": "2024-09-23T11:39:58+00:00"
},
{
"name": "php-http/message",
"version": "1.16.2",
"source": {
"type": "git",
"url": "https://github.com/php-http/message.git",
"reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/message/zipball/06dd5e8562f84e641bf929bfe699ee0f5ce8080a",
"reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a",
"shasum": ""
},
"require": {
"clue/stream-filter": "^1.5",
"php": "^7.2 || ^8.0",
"psr/http-message": "^1.1 || ^2.0"
},
"provide": {
"php-http/message-factory-implementation": "1.0"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.6",
"ext-zlib": "*",
"guzzlehttp/psr7": "^1.0 || ^2.0",
"laminas/laminas-diactoros": "^2.0 || ^3.0",
"php-http/message-factory": "^1.0.2",
"phpspec/phpspec": "^5.1 || ^6.3 || ^7.1",
"slim/slim": "^3.0"
},
"suggest": {
"ext-zlib": "Used with compressor/decompressor streams",
"guzzlehttp/psr7": "Used with Guzzle PSR-7 Factories",
"laminas/laminas-diactoros": "Used with Diactoros Factories",
"slim/slim": "Used with Slim Framework PSR-7 implementation"
},
"type": "library",
"autoload": {
"files": [
"src/filters.php"
],
"psr-4": {
"Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"description": "HTTP Message related tools",
"homepage": "http://php-http.org",
"keywords": [
"http",
"message",
"psr-7"
],
"support": {
"issues": "https://github.com/php-http/message/issues",
"source": "https://github.com/php-http/message/tree/1.16.2"
},
"time": "2024-10-02T11:34:13+00:00"
},
{
"name": "php-http/promise",
"version": "1.3.1",
@@ -10155,6 +10357,77 @@
],
"time": "2026-03-06T13:17:40+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v8.0.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/deprecation-contracts": "^2.5|^3"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\OptionsResolver\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides an improved replacement for the array_replace PHP function",
"homepage": "https://symfony.com",
"keywords": [
"config",
"configuration",
"options"
],
"support": {
"source": "https://github.com/symfony/options-resolver/tree/v8.0.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-11-12T15:55:31+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.33.0",
@@ -11973,6 +12246,77 @@
},
"time": "2025-12-02T11:56:42+00:00"
},
{
"name": "typesense/typesense-php",
"version": "v6.0.0",
"source": {
"type": "git",
"url": "https://github.com/typesense/typesense-php.git",
"reference": "e113f90449e159316a433a5509205939f271c5e2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/typesense/typesense-php/zipball/e113f90449e159316a433a5509205939f271c5e2",
"reference": "e113f90449e159316a433a5509205939f271c5e2",
"shasum": ""
},
"require": {
"ext-json": "*",
"monolog/monolog": "^2.1 || ^3.0 || ^3.3",
"nyholm/psr7": "^1.3",
"php": ">=7.4",
"php-http/client-common": "^1.0 || ^2.3",
"php-http/discovery": "^1.0",
"php-http/httplug": "^1.0 || ^2.2",
"psr/http-client-implementation": "^1.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.0 || ^2.0"
},
"require-dev": {
"mockery/mockery": "^1.6",
"phpunit/phpunit": "^11.2",
"squizlabs/php_codesniffer": "3.*",
"symfony/http-client": "^5.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Typesense\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Typesense",
"email": "contact@typesense.org",
"homepage": "https://typesense.org",
"role": "Developer"
},
{
"name": "Abdullah Al-Faqeir",
"email": "abdullah@devloops.net",
"homepage": "https://www.devloops.net",
"role": "Developer"
}
],
"description": "PHP client for Typesense Search Server: https://github.com/typesense/typesense",
"homepage": "https://github.com/typesense/typesense-php",
"support": {
"docs": "https://typesense.org/api",
"issues": "https://github.com/typesense/typesense-php/issues",
"source": "https://github.com/typesense/typesense-php"
},
"funding": [
{
"url": "https://github.com/typesense",
"type": "github"
}
],
"time": "2025-12-22T20:51:44+00:00"
},
{
"name": "ueberdosis/tiptap-php",
"version": "2.1.0",
+420
View File
@@ -2,6 +2,19 @@
declare(strict_types=1);
use App\Models\List\ExternalProfile;
use App\Models\List\Playlist;
use App\Models\Wiki\Anime;
use App\Models\Wiki\Anime\AnimeSynonym;
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;
return [
/*
@@ -141,4 +154,411 @@ return [
],
],
/*
|--------------------------------------------------------------------------
| Typesense Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Typesense settings. Typesense is an open
| source search engine using minimal configuration. Below, you will
| state the host, key, and schema configuration for the instance.
|
*/
'typesense' => [
'client-settings' => [
'api_key' => env('TYPESENSE_API_KEY', 'xyz'),
'nodes' => [
[
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
],
'nearest_node' => [
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2),
'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30),
'num_retries' => env('TYPESENSE_NUM_RETRIES', 3),
'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1),
],
'model-settings' => [
ExternalProfile::class => [
'collection-schema' => [
'fields' => [
[
'name' => 'id',
'type' => 'string',
],
[
'name' => 'name',
'type' => 'string',
],
],
],
'search-parameters' => [
'query_by' => 'name',
],
],
Playlist::class => [
'collection-schema' => [
'fields' => [
[
'name' => 'id',
'type' => 'string',
],
[
'name' => 'name',
'type' => 'string',
],
],
],
'search-parameters' => [
'query_by' => 'name',
],
],
Anime::class => [
'collection-schema' => [
'fields' => [
[
'name' => 'id',
'type' => 'string',
],
[
'name' => 'name',
'type' => 'string',
],
[
'name' => 'season',
'type' => 'string',
'optional' => true,
],
[
'name' => 'year',
'type' => 'int32',
],
[
'name' => 'created_at',
'type' => 'int64',
'optional' => true,
],
[
'name' => 'updated_at',
'type' => 'int64',
'optional' => true,
],
[
'name' => '__soft_deleted',
'type' => 'int32',
'optional' => true,
],
[
'name' => 'synonyms',
'type' => 'string[]',
],
],
],
'search-parameters' => [
'query_by' => 'name,synonyms',
],
],
AnimeTheme::class => [
'collection-schema' => [
'enable_nested_fields' => true,
'fields' => [
[
'name' => 'id',
'type' => 'string',
],
[
'name' => 'created_at',
'type' => 'int64',
'optional' => true,
],
[
'name' => 'type',
'type' => 'string',
],
[
'name' => 'sequence',
'type' => 'string',
'optional' => true,
],
[
'name' => 'type_sequence',
'type' => 'string',
],
[
'name' => 'anime',
'type' => 'object',
],
[
'name' => 'song',
'type' => 'object',
'optional' => true,
],
],
],
'search-parameters' => [
'query_by' => implode(',', [
'song.title',
'song.title_native',
'anime.name',
'anime.synonyms',
'type_sequence',
]),
'query_by_weights' => '10,8,6,5,5',
],
],
AnimeThemeEntry::class => [
'collection-schema' => [
'enable_nested_fields' => true,
'fields' => [
[
'name' => 'id',
'type' => 'string',
],
[
'name' => 'created_at',
'type' => 'int64',
'optional' => true,
],
[
'name' => 'version',
'type' => 'string',
],
[
'name' => 'type_sequence_version',
'type' => 'string',
],
[
'name' => 'animetheme',
'type' => 'object',
],
],
],
'search-parameters' => [
'query_by' => implode(',', [
'animetheme.song.title',
'animetheme.song.title_native',
'animetheme.anime.name',
'animetheme.anime.synonyms',
'type_sequence_version',
]),
'query_by_weights' => '10,8,6,5,5',
],
],
Artist::class => [
'collection-schema' => [
'enable_nested_fields' => true,
'fields' => [
[
'name' => 'id',
'type' => 'string',
],
[
'name' => 'name',
'type' => 'string',
],
[
'name' => 'synonyms',
'type' => 'string[]',
],
[
'name' => 'as',
'type' => 'string[]',
],
[
'name' => 'created_at',
'type' => 'int64',
'optional' => true,
],
[
'name' => 'search_text',
'type' => 'string',
],
],
],
'search-parameters' => [
'query_by' => implode(',', [
'name',
'synonyms',
'as',
'search_text',
]),
'query_by_weights' => '10,8,3,1',
'text_match_type' => 'sum_score',
],
],
Series::class => [
'collection-schema' => [
'enable_nested_fields' => true,
'fields' => [
[
'name' => 'id',
'type' => 'string',
],
[
'name' => 'name',
'type' => 'string',
'sort' => true,
],
[
'name' => 'created_at',
'type' => 'int64',
'optional' => true,
],
[
'name' => 'anime',
'type' => 'object[]',
],
],
],
'search-parameters' => [
'query_by' => implode(',', [
'name',
'anime.synonyms',
]),
'query_by_weights' => '10,8',
],
],
Song::class => [
'collection-schema' => [
'fields' => [
[
'name' => 'id',
'type' => 'string',
'sort' => true,
],
[
'name' => 'title',
'type' => 'string',
'optional' => true,
],
[
'name' => 'title_native',
'type' => 'string',
'optional' => true,
],
[
'name' => 'created_at',
'type' => 'int64',
'optional' => true,
],
],
],
'search-parameters' => [
'query_by' => 'title,title_native',
'query_by_weights' => '10,8',
],
],
Studio::class => [
'collection-schema' => [
'fields' => [
[
'name' => 'id',
'type' => 'string',
],
[
'name' => 'name',
'type' => 'string',
],
[
'name' => 'created_at',
'type' => 'int64',
'optional' => true,
],
],
],
'search-parameters' => [
'query_by' => 'name',
],
],
AnimeSynonym::class => [
'collection-schema' => [
'fields' => [
[
'name' => 'id',
'type' => 'string',
],
[
'name' => 'text',
'type' => 'string',
'sort' => true,
],
],
],
'search-parameters' => [
'query_by' => 'text',
],
],
Synonym::class => [
'collection-schema' => [
'fields' => [
[
'name' => 'id',
'type' => 'string',
],
[
'name' => 'text',
'type' => 'string',
],
[
'name' => 'created_at',
'type' => 'int64',
'optional' => true,
],
],
],
'search-parameters' => [
'query_by' => 'text',
],
],
Video::class => [
'collection-schema' => [
'enable_nested_fields' => true,
'fields' => [
[
'name' => 'id',
'type' => 'string',
],
[
'name' => 'created_at',
'type' => 'int64',
'optional' => true,
],
[
'name' => 'filename',
'type' => 'string',
],
[
'name' => 'tags',
'type' => 'string',
],
[
'name' => 'entries',
'type' => 'object[]',
],
],
],
'search-parameters' => [
'query_by' => implode(',', [
'filename',
'tags',
'entries.animetheme.song.title',
'entries.animetheme.song.title_native',
'entries.animetheme.anime.name',
'entries.animetheme.anime.synonyms',
'entries.type_sequence_version',
]),
'query_by_weights' => '10,8,6,7,5,5,5',
],
],
],
],
];
+3
View File
@@ -33,6 +33,9 @@ parameters:
- '#Access to an undefined property App\\Models\\Wiki\\Artist::\$pivot.#'
- "#BelongsToMany<.*,\\s*'pivot'> but returns .*BelongsToMany<.*,\\s*string>.#"
- '#Call to method assertActionMounted\(\) on an unknown class static.#'
-
identifier: nullsafe.neverNull
path: app/Scout/Typesense/Models/*
-
message: '#Call to an undefined method PHPUnit\\Framework\\TestCase::.*\(\)#'
path: tests/*
+1 -1
View File
@@ -28,7 +28,7 @@
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="SCOUT_QUEUE" value="false"/>
<env name="SCOUT_DRIVER" value="null"/>
<env name="SCOUT_DRIVER" value="collection"/>
<env name="HORIZON_TEAM_ID" value="null"/>
<env name="TEAM_CREATOR_ID" value="null"/>
</php>
@@ -3,7 +3,7 @@
declare(strict_types=1);
use App\Http\Api\Parser\SearchParser;
use App\Search\Criteria;
use App\Scout\Criteria;
uses(Illuminate\Foundation\Testing\WithFaker::class);
+1 -1
View File
@@ -13,7 +13,7 @@ use App\Http\Api\Parser\FilterParser;
use App\Http\Api\Parser\IncludeParser;
use App\Http\Api\Parser\SearchParser;
use App\Http\Api\Parser\SortParser;
use App\Search\Criteria as SearchCriteria;
use App\Scout\Criteria as SearchCriteria;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Tests\Unit\Http\Api\Query\FakeQuery;