diff --git a/.env.example b/.env.example index c16e470f9..b37762ab2 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.env.example-sail b/.env.example-sail index a68889143..2f3e33cc8 100644 --- a/.env.example-sail +++ b/.env.example-sail @@ -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 diff --git a/README.md b/README.md index c61161a8a..fa5ea57a5 100644 --- a/README.md +++ b/README.md @@ -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" ``` diff --git a/app/Actions/GraphQL/IndexAction.php b/app/Actions/GraphQL/IndexAction.php index f85be0d6b..945ded131 100644 --- a/app/Actions/GraphQL/IndexAction.php +++ b/app/Actions/GraphQL/IndexAction.php @@ -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); } } diff --git a/app/Actions/Http/Api/IndexAction.php b/app/Actions/Http/Api/IndexAction.php index d0d253ce2..352d93ceb 100644 --- a/app/Actions/Http/Api/IndexAction.php +++ b/app/Actions/Http/Api/IndexAction.php @@ -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, - }; - } } diff --git a/app/Actions/Models/Wiki/Image/OptimizeImageAction.php b/app/Actions/Models/Wiki/Image/OptimizeImageAction.php index a595f7b64..f4924ca04 100644 --- a/app/Actions/Models/Wiki/Image/OptimizeImageAction.php +++ b/app/Actions/Models/Wiki/Image/OptimizeImageAction.php @@ -83,6 +83,7 @@ class OptimizeImageAction protected function handleFFmpeg(): ?string { $filesToDelete = []; + $imagePath = null; try { Storage::disk('local')->put( diff --git a/app/Contracts/Search/SearchDriver.php b/app/Contracts/Search/SearchDriver.php deleted file mode 100644 index c3b6ed749..000000000 --- a/app/Contracts/Search/SearchDriver.php +++ /dev/null @@ -1,45 +0,0 @@ - $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; -} diff --git a/app/Filament/Components/Fields/BelongsTo.php b/app/Filament/Components/Fields/BelongsTo.php index bfea51456..8e788af38 100644 --- a/app/Filament/Components/Fields/BelongsTo.php +++ b/app/Filament/Components/Fields/BelongsTo.php @@ -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)]) diff --git a/app/Filament/Components/Fields/Select.php b/app/Filament/Components/Fields/Select.php index 82401a262..f586f729b 100644 --- a/app/Filament/Components/Fields/Select.php +++ b/app/Filament/Components/Fields/Select.php @@ -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)]) diff --git a/app/Filament/Components/Fields/SubmissionBelongsTo.php b/app/Filament/Components/Fields/SubmissionBelongsTo.php index e8a2511ab..b2c554432 100644 --- a/app/Filament/Components/Fields/SubmissionBelongsTo.php +++ b/app/Filament/Components/Fields/SubmissionBelongsTo.php @@ -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)]) diff --git a/app/Filament/Providers/GlobalSearchScoutProvider.php b/app/Filament/Providers/GlobalSearchScoutProvider.php index 5f3a942d1..b1db5118d 100644 --- a/app/Filament/Providers/GlobalSearchScoutProvider.php +++ b/app/Filament/Providers/GlobalSearchScoutProvider.php @@ -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 { diff --git a/app/Filament/Resources/Base/BaseListResources.php b/app/Filament/Resources/Base/BaseListResources.php index 9783e7927..6326f1218 100644 --- a/app/Filament/Resources/Base/BaseListResources.php +++ b/app/Filament/Resources/Base/BaseListResources.php @@ -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) diff --git a/app/Filament/Resources/Wiki/Anime/ThemeResource.php b/app/Filament/Resources/Wiki/Anime/ThemeResource.php index 6eeb07b97..46ed627a8 100644 --- a/app/Filament/Resources/Wiki/Anime/ThemeResource.php +++ b/app/Filament/Resources/Wiki/Anime/ThemeResource.php @@ -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 diff --git a/app/GraphQL/Schema/Queries/SearchQuery.php b/app/GraphQL/Schema/Queries/SearchQuery.php index fe6c60050..e913c97d4 100644 --- a/app/GraphQL/Schema/Queries/SearchQuery.php +++ b/app/GraphQL/Schema/Queries/SearchQuery.php @@ -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()); } } diff --git a/app/Http/Api/Parser/SearchParser.php b/app/Http/Api/Parser/SearchParser.php index cd9c8a666..2a01aa328 100644 --- a/app/Http/Api/Parser/SearchParser.php +++ b/app/Http/Api/Parser/SearchParser.php @@ -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 diff --git a/app/Http/Api/Query/Query.php b/app/Http/Api/Query/Query.php index 2077470c7..c45d26167 100644 --- a/app/Http/Api/Query/Query.php +++ b/app/Http/Api/Query/Query.php @@ -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 diff --git a/app/Models/List/ExternalProfile.php b/app/Models/List/ExternalProfile.php index ea7faedea..c0fe8e2f9 100644 --- a/app/Models/List/ExternalProfile.php +++ b/app/Models/List/ExternalProfile.php @@ -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."), + }; } /** diff --git a/app/Models/List/Playlist.php b/app/Models/List/Playlist.php index 528eda387..b0700f404 100644 --- a/app/Models/List/Playlist.php +++ b/app/Models/List/Playlist.php @@ -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 + * 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."), + }; } /** diff --git a/app/Models/Wiki/Anime.php b/app/Models/Wiki/Anime.php index b661806c4..c4ff73490 100644 --- a/app/Models/Wiki/Anime.php +++ b/app/Models/Wiki/Anime.php @@ -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."), + }; } /** diff --git a/app/Models/Wiki/Anime/AnimeSynonym.php b/app/Models/Wiki/Anime/AnimeSynonym.php index 9a0d73dea..661511f42 100644 --- a/app/Models/Wiki/Anime/AnimeSynonym.php +++ b/app/Models/Wiki/Anime/AnimeSynonym.php @@ -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; diff --git a/app/Models/Wiki/Anime/AnimeTheme.php b/app/Models/Wiki/Anime/AnimeTheme.php index 84750a87e..f90fbcff7 100644 --- a/app/Models/Wiki/Anime/AnimeTheme.php +++ b/app/Models/Wiki/Anime/AnimeTheme.php @@ -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 diff --git a/app/Models/Wiki/Anime/Theme/AnimeThemeEntry.php b/app/Models/Wiki/Anime/Theme/AnimeThemeEntry.php index 51fad5750..dde1911ed 100644 --- a/app/Models/Wiki/Anime/Theme/AnimeThemeEntry.php +++ b/app/Models/Wiki/Anime/Theme/AnimeThemeEntry.php @@ -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 diff --git a/app/Models/Wiki/Artist.php b/app/Models/Wiki/Artist.php index b5d1a803b..18b5b12f8 100644 --- a/app/Models/Wiki/Artist.php +++ b/app/Models/Wiki/Artist.php @@ -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."), + }; } /** diff --git a/app/Models/Wiki/Series.php b/app/Models/Wiki/Series.php index 52ff6b2e7..ab30e5b22 100644 --- a/app/Models/Wiki/Series.php +++ b/app/Models/Wiki/Series.php @@ -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 $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."), + }; } /** diff --git a/app/Models/Wiki/Song.php b/app/Models/Wiki/Song.php index 383394cf3..6db9ae0ec 100644 --- a/app/Models/Wiki/Song.php +++ b/app/Models/Wiki/Song.php @@ -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 $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)) { diff --git a/app/Models/Wiki/Studio.php b/app/Models/Wiki/Studio.php index 528a39149..be6cc10e8 100644 --- a/app/Models/Wiki/Studio.php +++ b/app/Models/Wiki/Studio.php @@ -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 $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. * diff --git a/app/Models/Wiki/Synonym.php b/app/Models/Wiki/Synonym.php index ac7202408..fb29a68c3 100644 --- a/app/Models/Wiki/Synonym.php +++ b/app/Models/Wiki/Synonym.php @@ -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; diff --git a/app/Models/Wiki/Video.php b/app/Models/Wiki/Video.php index 676fd7818..b2812dda2 100644 --- a/app/Models/Wiki/Video.php +++ b/app/Models/Wiki/Video.php @@ -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 $animethemeentries @@ -247,18 +251,15 @@ class Video extends BaseModel implements Auditable, SoftDeletable, Streamable /** * Get the indexable data array for the model. - * - * @return array */ 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."), + }; } /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 490bdfe7d..7d276b970 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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()) { diff --git a/app/Scout/Collection/CollectionSearch.php b/app/Scout/Collection/CollectionSearch.php new file mode 100644 index 000000000..bfff7e409 --- /dev/null +++ b/app/Scout/Collection/CollectionSearch.php @@ -0,0 +1,57 @@ +search(); + } + + /** + * @param Closure(EloquentBuilder): void $callback + * @param array $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; + } +} diff --git a/app/Search/Criteria.php b/app/Scout/Criteria.php similarity index 91% rename from app/Search/Criteria.php rename to app/Scout/Criteria.php index ce5aae88c..71c8f58ec 100644 --- a/app/Search/Criteria.php +++ b/app/Scout/Criteria.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Search; +namespace App\Scout; readonly class Criteria { diff --git a/app/Scout/Elasticsearch/Api/Query/ElasticQuery.php b/app/Scout/Elasticsearch/Api/Query/ElasticQuery.php index 6b1c4b46a..9c5d78b0d 100644 --- a/app/Scout/Elasticsearch/Api/Query/ElasticQuery.php +++ b/app/Scout/Elasticsearch/Api/Query/ElasticQuery.php @@ -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 diff --git a/app/Scout/Elasticsearch/Api/Query/List/PlaylistQuery.php b/app/Scout/Elasticsearch/Api/Query/List/PlaylistQuery.php index 9561b70bc..358406c43 100644 --- a/app/Scout/Elasticsearch/Api/Query/List/PlaylistQuery.php +++ b/app/Scout/Elasticsearch/Api/Query/List/PlaylistQuery.php @@ -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; diff --git a/app/Scout/Elasticsearch/Api/Query/Wiki/Anime/SynonymQuery.php b/app/Scout/Elasticsearch/Api/Query/Wiki/Anime/SynonymQuery.php index 53026a844..ef7d9aac0 100644 --- a/app/Scout/Elasticsearch/Api/Query/Wiki/Anime/SynonymQuery.php +++ b/app/Scout/Elasticsearch/Api/Query/Wiki/Anime/SynonymQuery.php @@ -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; diff --git a/app/Scout/Elasticsearch/Api/Query/Wiki/Anime/Theme/EntryQuery.php b/app/Scout/Elasticsearch/Api/Query/Wiki/Anime/Theme/EntryQuery.php index 55dfdd033..2679a3ca0 100644 --- a/app/Scout/Elasticsearch/Api/Query/Wiki/Anime/Theme/EntryQuery.php +++ b/app/Scout/Elasticsearch/Api/Query/Wiki/Anime/Theme/EntryQuery.php @@ -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; diff --git a/app/Scout/Elasticsearch/Api/Query/Wiki/Anime/ThemeQuery.php b/app/Scout/Elasticsearch/Api/Query/Wiki/Anime/ThemeQuery.php index ab2881fa6..1c3f55312 100644 --- a/app/Scout/Elasticsearch/Api/Query/Wiki/Anime/ThemeQuery.php +++ b/app/Scout/Elasticsearch/Api/Query/Wiki/Anime/ThemeQuery.php @@ -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; diff --git a/app/Scout/Elasticsearch/Api/Query/Wiki/AnimeQuery.php b/app/Scout/Elasticsearch/Api/Query/Wiki/AnimeQuery.php index 03216251f..c8827cbc9 100644 --- a/app/Scout/Elasticsearch/Api/Query/Wiki/AnimeQuery.php +++ b/app/Scout/Elasticsearch/Api/Query/Wiki/AnimeQuery.php @@ -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; diff --git a/app/Scout/Elasticsearch/Api/Query/Wiki/ArtistQuery.php b/app/Scout/Elasticsearch/Api/Query/Wiki/ArtistQuery.php index 3040915fd..f85352f1d 100644 --- a/app/Scout/Elasticsearch/Api/Query/Wiki/ArtistQuery.php +++ b/app/Scout/Elasticsearch/Api/Query/Wiki/ArtistQuery.php @@ -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; diff --git a/app/Scout/Elasticsearch/Api/Query/Wiki/SeriesQuery.php b/app/Scout/Elasticsearch/Api/Query/Wiki/SeriesQuery.php index e29c6661a..359b4d5c1 100644 --- a/app/Scout/Elasticsearch/Api/Query/Wiki/SeriesQuery.php +++ b/app/Scout/Elasticsearch/Api/Query/Wiki/SeriesQuery.php @@ -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; diff --git a/app/Scout/Elasticsearch/Api/Query/Wiki/SongQuery.php b/app/Scout/Elasticsearch/Api/Query/Wiki/SongQuery.php index 25aca1d0f..3cc3aae70 100644 --- a/app/Scout/Elasticsearch/Api/Query/Wiki/SongQuery.php +++ b/app/Scout/Elasticsearch/Api/Query/Wiki/SongQuery.php @@ -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; diff --git a/app/Scout/Elasticsearch/Api/Query/Wiki/StudioQuery.php b/app/Scout/Elasticsearch/Api/Query/Wiki/StudioQuery.php index 15d22939b..3457b9d29 100644 --- a/app/Scout/Elasticsearch/Api/Query/Wiki/StudioQuery.php +++ b/app/Scout/Elasticsearch/Api/Query/Wiki/StudioQuery.php @@ -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; diff --git a/app/Scout/Elasticsearch/Api/Query/Wiki/SynonymQuery.php b/app/Scout/Elasticsearch/Api/Query/Wiki/SynonymQuery.php index 6b9e74aa6..7cb4341fc 100644 --- a/app/Scout/Elasticsearch/Api/Query/Wiki/SynonymQuery.php +++ b/app/Scout/Elasticsearch/Api/Query/Wiki/SynonymQuery.php @@ -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; diff --git a/app/Scout/Elasticsearch/Api/Query/Wiki/VideoQuery.php b/app/Scout/Elasticsearch/Api/Query/Wiki/VideoQuery.php index eb1cc0091..6b275c293 100644 --- a/app/Scout/Elasticsearch/Api/Query/Wiki/VideoQuery.php +++ b/app/Scout/Elasticsearch/Api/Query/Wiki/VideoQuery.php @@ -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; diff --git a/app/Scout/Elasticsearch/Elasticsearch.php b/app/Scout/Elasticsearch/Elasticsearch.php index a9b3e0333..844e02e90 100644 --- a/app/Scout/Elasticsearch/Elasticsearch.php +++ b/app/Scout/Elasticsearch/Elasticsearch.php @@ -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 $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 + */ + 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}"), + }; } /** diff --git a/app/Scout/Elasticsearch/Models/List/ExternalProfileElasticModel.php b/app/Scout/Elasticsearch/Models/List/ExternalProfileElasticModel.php new file mode 100644 index 000000000..6809d18d8 --- /dev/null +++ b/app/Scout/Elasticsearch/Models/List/ExternalProfileElasticModel.php @@ -0,0 +1,18 @@ + + */ + public static function toSearchableArray(ExternalProfile $profile): array + { + return $profile->attributesToArray(); + } +} diff --git a/app/Scout/Elasticsearch/Models/List/PlaylistElasticModel.php b/app/Scout/Elasticsearch/Models/List/PlaylistElasticModel.php new file mode 100644 index 000000000..fceff9075 --- /dev/null +++ b/app/Scout/Elasticsearch/Models/List/PlaylistElasticModel.php @@ -0,0 +1,18 @@ + + */ + public static function toSearchableArray(Playlist $playlist): array + { + return $playlist->attributesToArray(); + } +} diff --git a/app/Scout/Elasticsearch/Models/Wiki/Anime/AnimeSynonymElasticModel.php b/app/Scout/Elasticsearch/Models/Wiki/Anime/AnimeSynonymElasticModel.php new file mode 100644 index 000000000..cfd534966 --- /dev/null +++ b/app/Scout/Elasticsearch/Models/Wiki/Anime/AnimeSynonymElasticModel.php @@ -0,0 +1,18 @@ + + */ + public static function toSearchableArray(AnimeSynonym $synonym): array + { + return $synonym->attributesToArray(); + } +} diff --git a/app/Scout/Elasticsearch/Models/Wiki/Anime/AnimeThemeElasticModel.php b/app/Scout/Elasticsearch/Models/Wiki/Anime/AnimeThemeElasticModel.php new file mode 100644 index 000000000..b4ddc1e99 --- /dev/null +++ b/app/Scout/Elasticsearch/Models/Wiki/Anime/AnimeThemeElasticModel.php @@ -0,0 +1,27 @@ + + */ + 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; + } +} diff --git a/app/Scout/Elasticsearch/Models/Wiki/Anime/Theme/AnimeThemeEntryElasticModel.php b/app/Scout/Elasticsearch/Models/Wiki/Anime/Theme/AnimeThemeEntryElasticModel.php new file mode 100644 index 000000000..f00073755 --- /dev/null +++ b/app/Scout/Elasticsearch/Models/Wiki/Anime/Theme/AnimeThemeEntryElasticModel.php @@ -0,0 +1,23 @@ + + */ + public static function toSearchableArray(AnimeThemeEntry $entry): array + { + return [ + ...$entry->attributesToArray(), + 'theme' => $entry->animetheme->toSearchableArray(), + 'version' => Str::of(strval($entry->version))->prepend('v')->__toString(), + ]; + } +} diff --git a/app/Scout/Elasticsearch/Models/Wiki/AnimeElasticModel.php b/app/Scout/Elasticsearch/Models/Wiki/AnimeElasticModel.php new file mode 100644 index 000000000..e10ec654b --- /dev/null +++ b/app/Scout/Elasticsearch/Models/Wiki/AnimeElasticModel.php @@ -0,0 +1,22 @@ + + */ + public static function toSearchableArray(Anime $anime): array + { + return [ + ...$anime->attributesToArray(), + 'synonyms' => $anime->synonyms->map(fn (Synonym $synonym) => $synonym->text)->all(), + ]; + } +} diff --git a/app/Scout/Elasticsearch/Models/Wiki/ArtistElasticModel.php b/app/Scout/Elasticsearch/Models/Wiki/ArtistElasticModel.php new file mode 100644 index 000000000..0938e850e --- /dev/null +++ b/app/Scout/Elasticsearch/Models/Wiki/ArtistElasticModel.php @@ -0,0 +1,31 @@ + + */ + 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(), + ]; + } +} diff --git a/app/Scout/Elasticsearch/Models/Wiki/SeriesElasticModel.php b/app/Scout/Elasticsearch/Models/Wiki/SeriesElasticModel.php new file mode 100644 index 000000000..bb103e3da --- /dev/null +++ b/app/Scout/Elasticsearch/Models/Wiki/SeriesElasticModel.php @@ -0,0 +1,24 @@ + + */ + public static function toSearchableArray(Series $series): array + { + return [ + ...$series->attributesToArray(), + 'anime' => $series->anime->map( + fn (Anime $anime): array => $anime->toSearchableArray() + )->all(), + ]; + } +} diff --git a/app/Scout/Elasticsearch/Models/Wiki/SongElasticModel.php b/app/Scout/Elasticsearch/Models/Wiki/SongElasticModel.php new file mode 100644 index 000000000..5fed9c998 --- /dev/null +++ b/app/Scout/Elasticsearch/Models/Wiki/SongElasticModel.php @@ -0,0 +1,18 @@ + + */ + public static function toSearchableArray(Song $song): array + { + return $song->attributesToArray(); + } +} diff --git a/app/Scout/Elasticsearch/Models/Wiki/StudioElasticModel.php b/app/Scout/Elasticsearch/Models/Wiki/StudioElasticModel.php new file mode 100644 index 000000000..10bb5b0fc --- /dev/null +++ b/app/Scout/Elasticsearch/Models/Wiki/StudioElasticModel.php @@ -0,0 +1,18 @@ + + */ + public static function toSearchableArray(Studio $studio): array + { + return $studio->attributesToArray(); + } +} diff --git a/app/Scout/Elasticsearch/Models/Wiki/SynonymElasticModel.php b/app/Scout/Elasticsearch/Models/Wiki/SynonymElasticModel.php new file mode 100644 index 000000000..de4daa9e2 --- /dev/null +++ b/app/Scout/Elasticsearch/Models/Wiki/SynonymElasticModel.php @@ -0,0 +1,18 @@ + + */ + public static function toSearchableArray(Synonym $synonym): array + { + return $synonym->attributesToArray(); + } +} diff --git a/app/Scout/Elasticsearch/Models/Wiki/VideoElasticModel.php b/app/Scout/Elasticsearch/Models/Wiki/VideoElasticModel.php new file mode 100644 index 000000000..967471bae --- /dev/null +++ b/app/Scout/Elasticsearch/Models/Wiki/VideoElasticModel.php @@ -0,0 +1,24 @@ + + */ + public static function toSearchableArray(Video $video): array + { + return [ + ...$video->attributesToArray(), + 'entries' => $video->animethemeentries->map( + fn (AnimeThemeEntry $entry): array => $entry->toSearchableArray() + )->all(), + ]; + } +} diff --git a/app/Scout/Search.php b/app/Scout/Search.php index 318e5ef3c..eff03e530 100644 --- a/app/Scout/Search.php +++ b/app/Scout/Search.php @@ -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 + */ + 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 $sorts + */ + abstract public function search(?Closure $callback = null, int $perPage = 15, int $page = 1, array $sorts = []): LengthAwarePaginator; } diff --git a/app/Scout/Typesense/Models/List/ExternalProfileTypesenseModel.php b/app/Scout/Typesense/Models/List/ExternalProfileTypesenseModel.php new file mode 100644 index 000000000..9b9bdae70 --- /dev/null +++ b/app/Scout/Typesense/Models/List/ExternalProfileTypesenseModel.php @@ -0,0 +1,21 @@ + + */ + public static function toSearchableArray(ExternalProfile $profile): array + { + return [ + ...$profile->attributesToArray(), + 'id' => (string) $profile->getKey(), + ]; + } +} diff --git a/app/Scout/Typesense/Models/List/PlaylistTypesenseModel.php b/app/Scout/Typesense/Models/List/PlaylistTypesenseModel.php new file mode 100644 index 000000000..40095730f --- /dev/null +++ b/app/Scout/Typesense/Models/List/PlaylistTypesenseModel.php @@ -0,0 +1,21 @@ + + */ + public static function toSearchableArray(Playlist $playlist): array + { + return [ + ...$playlist->attributesToArray(), + 'id' => (string) $playlist->getKey(), + ]; + } +} diff --git a/app/Scout/Typesense/Models/Wiki/Anime/AnimeSynonymTypesenseModel.php b/app/Scout/Typesense/Models/Wiki/Anime/AnimeSynonymTypesenseModel.php new file mode 100644 index 000000000..9c5f5984a --- /dev/null +++ b/app/Scout/Typesense/Models/Wiki/Anime/AnimeSynonymTypesenseModel.php @@ -0,0 +1,24 @@ + + */ + 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, + ]; + } +} diff --git a/app/Scout/Typesense/Models/Wiki/Anime/AnimeThemeTypesenseModel.php b/app/Scout/Typesense/Models/Wiki/Anime/AnimeThemeTypesenseModel.php new file mode 100644 index 000000000..eed76a255 --- /dev/null +++ b/app/Scout/Typesense/Models/Wiki/Anime/AnimeThemeTypesenseModel.php @@ -0,0 +1,28 @@ + + */ + 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(), + ]; + } +} diff --git a/app/Scout/Typesense/Models/Wiki/Anime/Theme/AnimeThemeEntryTypesenseModel.php b/app/Scout/Typesense/Models/Wiki/Anime/Theme/AnimeThemeEntryTypesenseModel.php new file mode 100644 index 000000000..c9815e6f2 --- /dev/null +++ b/app/Scout/Typesense/Models/Wiki/Anime/Theme/AnimeThemeEntryTypesenseModel.php @@ -0,0 +1,26 @@ + + */ + 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, + ]; + } +} diff --git a/app/Scout/Typesense/Models/Wiki/AnimeTypesenseModel.php b/app/Scout/Typesense/Models/Wiki/AnimeTypesenseModel.php new file mode 100644 index 000000000..869f8533b --- /dev/null +++ b/app/Scout/Typesense/Models/Wiki/AnimeTypesenseModel.php @@ -0,0 +1,27 @@ + + */ + 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(), + ]; + } +} diff --git a/app/Scout/Typesense/Models/Wiki/ArtistTypesenseModel.php b/app/Scout/Typesense/Models/Wiki/ArtistTypesenseModel.php new file mode 100644 index 000000000..085f28863 --- /dev/null +++ b/app/Scout/Typesense/Models/Wiki/ArtistTypesenseModel.php @@ -0,0 +1,40 @@ + + */ + 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, + ]), + ]; + } +} diff --git a/app/Scout/Typesense/Models/Wiki/SeriesTypesenseModel.php b/app/Scout/Typesense/Models/Wiki/SeriesTypesenseModel.php new file mode 100644 index 000000000..7053370db --- /dev/null +++ b/app/Scout/Typesense/Models/Wiki/SeriesTypesenseModel.php @@ -0,0 +1,26 @@ + + */ + 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(), + ]; + } +} diff --git a/app/Scout/Typesense/Models/Wiki/SongTypesenseModel.php b/app/Scout/Typesense/Models/Wiki/SongTypesenseModel.php new file mode 100644 index 000000000..49ef367cd --- /dev/null +++ b/app/Scout/Typesense/Models/Wiki/SongTypesenseModel.php @@ -0,0 +1,24 @@ + + */ + 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, + ]; + } +} diff --git a/app/Scout/Typesense/Models/Wiki/StudioTypesenseModel.php b/app/Scout/Typesense/Models/Wiki/StudioTypesenseModel.php new file mode 100644 index 000000000..52f5c0266 --- /dev/null +++ b/app/Scout/Typesense/Models/Wiki/StudioTypesenseModel.php @@ -0,0 +1,22 @@ + + */ + public static function toSearchableArray(Studio $studio): array + { + return [ + ...$studio->attributesToArray(), + 'id' => (string) $studio->getKey(), + 'created_at' => $studio->created_at?->timestamp, + ]; + } +} diff --git a/app/Scout/Typesense/Models/Wiki/SynonymTypesenseModel.php b/app/Scout/Typesense/Models/Wiki/SynonymTypesenseModel.php new file mode 100644 index 000000000..48e5be107 --- /dev/null +++ b/app/Scout/Typesense/Models/Wiki/SynonymTypesenseModel.php @@ -0,0 +1,22 @@ + + */ + public static function toSearchableArray(Synonym $synonym): array + { + return [ + ...$synonym->attributesToArray(), + 'id' => (string) $synonym->getKey(), + 'created_at' => $synonym->created_at?->timestamp, + ]; + } +} diff --git a/app/Scout/Typesense/Models/Wiki/VideoTypesenseModel.php b/app/Scout/Typesense/Models/Wiki/VideoTypesenseModel.php new file mode 100644 index 000000000..d1e7700a3 --- /dev/null +++ b/app/Scout/Typesense/Models/Wiki/VideoTypesenseModel.php @@ -0,0 +1,28 @@ + + */ + 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(), + ]; + } +} diff --git a/app/Scout/Typesense/Typesense.php b/app/Scout/Typesense/Typesense.php new file mode 100644 index 000000000..5f389202f --- /dev/null +++ b/app/Scout/Typesense/Typesense.php @@ -0,0 +1,57 @@ +search(); + } + + /** + * @param Closure(EloquentBuilder): void $callback + * @param array $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; + } +} diff --git a/app/Search/Drivers/CollectionDriver.php b/app/Search/Drivers/CollectionDriver.php deleted file mode 100644 index 9adb0a700..000000000 --- a/app/Search/Drivers/CollectionDriver.php +++ /dev/null @@ -1,85 +0,0 @@ -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 $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()); - } -} diff --git a/app/Search/Drivers/ElasticsearchDriver.php b/app/Search/Drivers/ElasticsearchDriver.php deleted file mode 100644 index 89aa5bda0..000000000 --- a/app/Search/Drivers/ElasticsearchDriver.php +++ /dev/null @@ -1,154 +0,0 @@ -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 $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 - */ - 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}"), - }; - } -} diff --git a/app/Search/Search.php b/app/Search/Search.php deleted file mode 100644 index 35907f2b8..000000000 --- a/app/Search/Search.php +++ /dev/null @@ -1,29 +0,0 @@ -|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."), - }; - } -} diff --git a/composer.json b/composer.json index a49cab767..3fa1b8fad 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/composer.lock b/composer.lock index a45ef825d..021b88632 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/scout.php b/config/scout.php index 721d31e08..fb37c70c1 100644 --- a/config/scout.php +++ b/config/scout.php @@ -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', + ], + ], + ], + ], ]; diff --git a/phpstan.neon b/phpstan.neon index 46de32a23..08fc2baae 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -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/* diff --git a/phpunit.xml b/phpunit.xml index 3dde523f5..95c522ea5 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -28,7 +28,7 @@ - + diff --git a/tests/Unit/Http/Api/Parser/SearchParserTest.php b/tests/Unit/Http/Api/Parser/SearchParserTest.php index ffacce86e..a0933cd05 100644 --- a/tests/Unit/Http/Api/Parser/SearchParserTest.php +++ b/tests/Unit/Http/Api/Parser/SearchParserTest.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); diff --git a/tests/Unit/Http/Api/Query/QueryTest.php b/tests/Unit/Http/Api/Query/QueryTest.php index 540c46a70..743480777 100644 --- a/tests/Unit/Http/Api/Query/QueryTest.php +++ b/tests/Unit/Http/Api/Query/QueryTest.php @@ -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;