diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..dd49d2667 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Integration Tests +# Copy this file to .env and configure to run integration tests locally. +# Tests are skipped by default. Set the RUN_*_INTEGRATION_TESTS vars to enable. + +# Meilisearch Integration Tests +RUN_MEILISEARCH_INTEGRATION_TESTS=false +MEILISEARCH_HOST=127.0.0.1 +MEILISEARCH_PORT=7700 +MEILISEARCH_KEY=secret + +# Typesense Integration Tests +RUN_TYPESENSE_INTEGRATION_TESTS=false +TYPESENSE_HOST=127.0.0.1 +TYPESENSE_PORT=8108 +TYPESENSE_API_KEY=secret +TYPESENSE_PROTOCOL=http diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aab39105b..b87e4f236 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,4 +36,80 @@ jobs: - name: Execute tests run: | PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run --diff - vendor/bin/phpunit -c phpunit.xml.dist + vendor/bin/phpunit -c phpunit.xml.dist --exclude-group integration + + meilisearch_integration_tests: + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[ci skip]')" + + name: Integration (Meilisearch) + + services: + meilisearch: + image: getmeili/meilisearch:latest + env: + MEILI_MASTER_KEY: secret + MEILI_NO_ANALYTICS: true + ports: + - 7700:7700 + options: >- + --health-cmd "curl -f http://localhost:7700/health" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + container: + image: phpswoole/swoole:6.0.2-php8.4 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o + + - name: Execute Meilisearch integration tests + env: + RUN_MEILISEARCH_INTEGRATION_TESTS: true + MEILISEARCH_HOST: meilisearch + MEILISEARCH_PORT: 7700 + MEILISEARCH_KEY: secret + run: | + vendor/bin/phpunit -c phpunit.xml.dist --group meilisearch-integration + + typesense_integration_tests: + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[ci skip]')" + + name: Integration (Typesense) + + services: + typesense: + image: typesense/typesense:27.1 + env: + TYPESENSE_API_KEY: secret + TYPESENSE_DATA_DIR: /tmp + ports: + - 8108:8108 + + container: + image: phpswoole/swoole:6.0.2-php8.4 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o + + - name: Execute Typesense integration tests + env: + RUN_TYPESENSE_INTEGRATION_TESTS: true + TYPESENSE_HOST: typesense + TYPESENSE_PORT: 8108 + TYPESENSE_API_KEY: secret + TYPESENSE_PROTOCOL: http + run: | + vendor/bin/phpunit -c phpunit.xml.dist --group typesense-integration diff --git a/.gitignore b/.gitignore index 7e1625b0f..935c6727f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ composer.lock /phpunit.xml .phpunit.result.cache +.env !tests/Foundation/fixtures/hyperf1/composer.lock tests/Http/fixtures .env diff --git a/composer.json b/composer.json index 6535b1eba..05e0e614d 100644 --- a/composer.json +++ b/composer.json @@ -58,6 +58,7 @@ "Hypervel\\Redis\\": "src/redis/src/", "Hypervel\\Router\\": "src/router/src/", "Hypervel\\Sanctum\\": "src/sanctum/src/", + "Hypervel\\Scout\\": "src/scout/src/", "Hypervel\\Session\\": "src/session/src/", "Hypervel\\Socialite\\": "src/socialite/src/", "Hypervel\\Support\\": "src/support/src/", @@ -174,6 +175,7 @@ "hypervel/queue": "self.version", "hypervel/redis": "self.version", "hypervel/router": "self.version", + "hypervel/scout": "self.version", "hypervel/session": "self.version", "hypervel/socialite": "self.version", "hypervel/support": "self.version", @@ -194,7 +196,9 @@ "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^6.2).", "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^6.2).", "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", - "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0)." + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "meilisearch/meilisearch-php": "Required to use the Meilisearch Scout driver (^1.16).", + "typesense/typesense-php": "Required to use the Typesense Scout driver (^5.2)." }, "require-dev": { "ably/ably-php": "^1.0", @@ -211,6 +215,7 @@ "league/flysystem-google-cloud-storage": "^3.0", "league/flysystem-path-prefixing": "^3.3", "league/flysystem-read-only": "^3.3", + "meilisearch/meilisearch-php": "^1.16", "mockery/mockery": "1.6.x-dev", "nunomaduro/collision": "^8.5", "pda/pheanstalk": "v5.0.9", @@ -218,7 +223,8 @@ "phpunit/phpunit": "10.5.45", "pusher/pusher-php-server": "^7.2", "swoole/ide-helper": "~5.1.0", - "symfony/yaml": "^7.3" + "symfony/yaml": "^7.3", + "typesense/typesense-php": "^5.2" }, "config": { "sort-packages": true @@ -260,6 +266,7 @@ "hypervel": { "providers": [ "Hypervel\\Notifications\\NotificationServiceProvider", + "Hypervel\\Scout\\ScoutServiceProvider", "Hypervel\\Telescope\\TelescopeServiceProvider", "Hypervel\\Sentry\\SentryServiceProvider" ] diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 1ca809110..719dbc862 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -43,6 +43,7 @@ parameters: - '#Call to an undefined method Hyperf\\Database\\Query\\Builder::firstOrFail\(\)#' - '#Access to an undefined property Hyperf\\Collection\\HigherOrderCollectionProxy#' - '#Call to an undefined method Hyperf\\Tappable\\HigherOrderTapProxy#' + - '#Trait Hypervel\\Scout\\Searchable is used zero times and is not analysed#' - message: '#.*#' paths: - src/core/src/Database/Eloquent/Builder.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index cc75f1b1b..e327f01ea 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,6 @@ env('SCOUT_DRIVER', 'collection'), + + /* + |-------------------------------------------------------------------------- + | Index Prefix + |-------------------------------------------------------------------------- + | + | Here you may specify a prefix that will be applied to all search index + | names used by Scout. This prefix may be useful if you have multiple + | "tenants" or applications sharing the same search infrastructure. + | + */ + + 'prefix' => env('SCOUT_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Queue Configuration + |-------------------------------------------------------------------------- + | + | This option allows you to control if the operations that sync your data + | with your search engines are queued. When enabled, all automatic data + | syncing will get queued for better performance. + | + | By default, Hypervel Scout uses Coroutine::defer() which executes + | indexing at coroutine exit (in HTTP requests, typically after the + | response is emitted). Set 'enabled' to true to use + | the queue system instead for durability and retries. + | + | The 'after_commit' option ensures that queued indexing jobs are only + | dispatched after all database transactions have committed, preventing + | indexing of data that might be rolled back. + | + */ + + 'queue' => [ + 'enabled' => env('SCOUT_QUEUE', false), + 'connection' => env('SCOUT_QUEUE_CONNECTION'), + 'queue' => env('SCOUT_QUEUE_NAME'), + 'after_commit' => env('SCOUT_AFTER_COMMIT', false), + ], + + /* + |-------------------------------------------------------------------------- + | Chunk Sizes + |-------------------------------------------------------------------------- + | + | These options allow you to control the maximum chunk size when you are + | mass importing data into the search engine. This allows you to fine + | tune each of these chunk sizes based on the power of the servers. + | + */ + + 'chunk' => [ + 'searchable' => 500, + 'unsearchable' => 500, + ], + + /* + |-------------------------------------------------------------------------- + | Command Concurrency + |-------------------------------------------------------------------------- + | + | This option controls the maximum number of concurrent coroutines used + | when running bulk import/flush operations via Scout commands. Higher + | values speed up imports but consume more resources. This only affects + | console commands, not HTTP request indexing. + | + */ + + 'command_concurrency' => env('SCOUT_COMMAND_CONCURRENCY', 50), + + /* + |-------------------------------------------------------------------------- + | Soft Deletes + |-------------------------------------------------------------------------- + | + | This option allows you to control whether to keep soft deleted records + | in the search indexes. Maintaining soft deleted records can be useful + | if your application still needs to search for the records later. + | + */ + + 'soft_delete' => env('SCOUT_SOFT_DELETE', false), + + /* + |-------------------------------------------------------------------------- + | Meilisearch Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Meilisearch settings. Meilisearch is an open + | source search engine with minimal configuration. Below, you can state + | the host and key information for your own Meilisearch installation. + | + | See: https://www.meilisearch.com/docs/learn/configuration/instance_options + | + */ + + 'meilisearch' => [ + 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'), + 'key' => env('MEILISEARCH_KEY'), + 'index-settings' => [ + // Per-index settings can be defined here: + // 'users' => [ + // 'filterableAttributes' => ['id', 'name', 'email'], + // 'sortableAttributes' => ['created_at'], + // ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Typesense Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Typesense settings. Typesense is a fast, + | typo-tolerant search engine optimized for instant search experiences. + | + | See: https://typesense.org/docs/ + | + */ + + 'typesense' => [ + 'client-settings' => [ + 'api_key' => env('TYPESENSE_API_KEY', ''), + 'nodes' => [ + [ + 'host' => env('TYPESENSE_HOST', 'localhost'), + 'port' => env('TYPESENSE_PORT', '8108'), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + ], + 'connection_timeout_seconds' => 2, + ], + 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000), + 'import_action' => 'upsert', + 'model-settings' => [ + // Per-model settings can be defined here: + // App\Models\User::class => [ + // 'collection-schema' => [ + // 'fields' => [ + // ['name' => 'id', 'type' => 'string'], + // ['name' => 'name', 'type' => 'string'], + // ['name' => 'created_at', 'type' => 'int64'], + // ], + // 'default_sorting_field' => 'created_at', + // ], + // 'search-parameters' => [ + // 'query_by' => 'name', + // ], + // ], + ], + ], +]; diff --git a/src/scout/src/Attributes/SearchUsingFullText.php b/src/scout/src/Attributes/SearchUsingFullText.php new file mode 100644 index 000000000..23ddd4e6d --- /dev/null +++ b/src/scout/src/Attributes/SearchUsingFullText.php @@ -0,0 +1,52 @@ + 'websearch', 'language' => 'english'])] + * public function toSearchableArray(): array + */ +#[Attribute(Attribute::TARGET_METHOD)] +class SearchUsingFullText +{ + /** + * The full-text columns. + * + * @var array + */ + public readonly array $columns; + + /** + * The full-text options. + * + * @var array + */ + public readonly array $options; + + /** + * Create a new attribute instance. + * + * @param array|string $columns + * @param array $options + */ + public function __construct(array|string $columns, array $options = []) + { + $this->columns = Arr::wrap($columns); + $this->options = $options; + } +} diff --git a/src/scout/src/Attributes/SearchUsingPrefix.php b/src/scout/src/Attributes/SearchUsingPrefix.php new file mode 100644 index 000000000..586c3afd2 --- /dev/null +++ b/src/scout/src/Attributes/SearchUsingPrefix.php @@ -0,0 +1,40 @@ + + */ + public readonly array $columns; + + /** + * Create a new attribute instance. + * + * @param array|string $columns + */ + public function __construct(array|string $columns) + { + $this->columns = Arr::wrap($columns); + } +} diff --git a/src/scout/src/Builder.php b/src/scout/src/Builder.php new file mode 100644 index 000000000..e6724f7eb --- /dev/null +++ b/src/scout/src/Builder.php @@ -0,0 +1,551 @@ + + */ + public array $wheres = []; + + /** + * The "where in" constraints added to the query. + * + * @var array> + */ + public array $whereIns = []; + + /** + * The "where not in" constraints added to the query. + * + * @var array> + */ + public array $whereNotIns = []; + + /** + * The "limit" that should be applied to the search. + */ + public ?int $limit = null; + + /** + * The "order" that should be applied to the search. + * + * @var array + */ + public array $orders = []; + + /** + * Extra options that should be applied to the search. + * + * @var array + */ + public array $options = []; + + /** + * Create a new search builder instance. + * + * @param TModel $model + */ + public function __construct( + Model $model, + string $query, + ?Closure $callback = null, + bool $softDelete = false + ) { + $this->model = $model; + $this->query = $query; + $this->callback = $callback; + + if ($softDelete) { + $this->wheres['__soft_deleted'] = 0; + } + } + + /** + * Specify a custom index to perform this search on. + * + * @return $this + */ + public function within(string $index): static + { + $this->index = $index; + + return $this; + } + + /** + * Add a constraint to the search query. + * + * @return $this + */ + public function where(string $field, mixed $value): static + { + $this->wheres[$field] = $value; + + return $this; + } + + /** + * Add a "where in" constraint to the search query. + * + * @param array|Arrayable $values + * @return $this + */ + public function whereIn(string $field, array|Arrayable $values): static + { + if ($values instanceof Arrayable) { + $values = $values->toArray(); + } + + $this->whereIns[$field] = $values; + + return $this; + } + + /** + * Add a "where not in" constraint to the search query. + * + * @param array|Arrayable $values + * @return $this + */ + public function whereNotIn(string $field, array|Arrayable $values): static + { + if ($values instanceof Arrayable) { + $values = $values->toArray(); + } + + $this->whereNotIns[$field] = $values; + + return $this; + } + + /** + * Include soft deleted records in the results. + * + * @return $this + */ + public function withTrashed(): static + { + unset($this->wheres['__soft_deleted']); + + return $this; + } + + /** + * Include only soft deleted records in the results. + * + * @return $this + */ + public function onlyTrashed(): static + { + return tap($this->withTrashed(), function () { + $this->wheres['__soft_deleted'] = 1; + }); + } + + /** + * Set the "limit" for the search query. + * + * @return $this + */ + public function take(int $limit): static + { + $this->limit = $limit; + + return $this; + } + + /** + * Add an "order" for the search query. + * + * @return $this + */ + public function orderBy(string $column, string $direction = 'asc'): static + { + $this->orders[] = [ + 'column' => $column, + 'direction' => strtolower($direction) === 'asc' ? 'asc' : 'desc', + ]; + + return $this; + } + + /** + * Add a descending "order by" clause to the search query. + * + * @return $this + */ + public function orderByDesc(string $column): static + { + return $this->orderBy($column, 'desc'); + } + + /** + * Add an "order by" clause for a timestamp to the query (descending). + * + * @return $this + */ + public function latest(?string $column = null): static + { + $column ??= $this->model->getCreatedAtColumn() ?? 'created_at'; + + return $this->orderBy($column, 'desc'); + } + + /** + * Add an "order by" clause for a timestamp to the query (ascending). + * + * @return $this + */ + public function oldest(?string $column = null): static + { + $column ??= $this->model->getCreatedAtColumn() ?? 'created_at'; + + return $this->orderBy($column, 'asc'); + } + + /** + * Set extra options for the search query. + * + * @param array $options + * @return $this + */ + public function options(array $options): static + { + $this->options = $options; + + return $this; + } + + /** + * Set the callback that should have an opportunity to modify the database query. + * + * @return $this + */ + public function query(callable $callback): static + { + $this->queryCallback = $callback(...); + + return $this; + } + + /** + * Get the raw results of the search. + */ + public function raw(): mixed + { + return $this->engine()->search($this); + } + + /** + * Set the callback that should have an opportunity to inspect and modify + * the raw result returned by the search engine. + * + * @return $this + */ + public function withRawResults(callable $callback): static + { + $this->afterRawSearchCallback = $callback(...); + + return $this; + } + + /** + * Get the keys of search results. + */ + public function keys(): Collection + { + return $this->engine()->keys($this); + } + + /** + * Get the first result from the search. + * + * @return null|TModel + */ + public function first(): ?Model + { + return $this->get()->first(); + } + + /** + * Get the results of the search. + * + * @return EloquentCollection + */ + public function get(): EloquentCollection + { + return $this->engine()->get($this); + } + + /** + * Get the results of the search as a lazy collection. + * + * @return LazyCollection + */ + public function cursor(): LazyCollection + { + return $this->engine()->cursor($this); + } + + /** + * Paginate the given query into a simple paginator. + */ + public function simplePaginate( + ?int $perPage = null, + string $pageName = 'page', + ?int $page = null + ): PaginatorInterface { + $engine = $this->engine(); + + $page = $page ?? Paginator::resolveCurrentPage($pageName); + $perPage = $perPage ?? $this->model->getPerPage(); + + if ($engine instanceof PaginatesEloquentModels) { + return $engine->simplePaginate($this, $perPage, $page)->appends('query', $this->query); + } + + if ($engine instanceof PaginatesEloquentModelsUsingDatabase) { + return $engine->simplePaginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', $this->query); + } + + $rawResults = $engine->paginate($this, $perPage, $page); + /** @var array $mappedModels */ + $mappedModels = $engine->map( + $this, + $this->applyAfterRawSearchCallback($rawResults), + $this->model + )->all(); + $results = $this->model->newCollection($mappedModels); + + return (new Paginator($results, $perPage, $page, [ + 'path' => Paginator::resolveCurrentPath(), + 'pageName' => $pageName, + ]))->hasMorePagesWhen( + ($perPage * $page) < $engine->getTotalCount($rawResults) + )->appends('query', $this->query); + } + + /** + * Paginate the given query into a length-aware paginator. + */ + public function paginate( + ?int $perPage = null, + string $pageName = 'page', + ?int $page = null + ): LengthAwarePaginatorInterface { + $engine = $this->engine(); + + $page = $page ?? Paginator::resolveCurrentPage($pageName); + $perPage = $perPage ?? $this->model->getPerPage(); + + if ($engine instanceof PaginatesEloquentModels) { + return $engine->paginate($this, $perPage, $page)->appends('query', $this->query); + } + + if ($engine instanceof PaginatesEloquentModelsUsingDatabase) { + return $engine->paginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', $this->query); + } + + $rawResults = $engine->paginate($this, $perPage, $page); + /** @var array $mappedModels */ + $mappedModels = $engine->map( + $this, + $this->applyAfterRawSearchCallback($rawResults), + $this->model + )->all(); + $results = $this->model->newCollection($mappedModels); + + return (new LengthAwarePaginator( + $results, + $this->getTotalCount($rawResults), + $perPage, + $page, + [ + 'path' => Paginator::resolveCurrentPath(), + 'pageName' => $pageName, + ] + ))->appends('query', $this->query); + } + + /** + * Paginate the given query into a length-aware paginator with raw data. + */ + public function paginateRaw( + ?int $perPage = null, + string $pageName = 'page', + ?int $page = null + ): LengthAwarePaginator { + $engine = $this->engine(); + + $page = $page ?? Paginator::resolveCurrentPage($pageName); + $perPage = $perPage ?? $this->model->getPerPage(); + + $results = $this->applyAfterRawSearchCallback( + $engine->paginate($this, $perPage, $page) + ); + + return (new LengthAwarePaginator( + $results, + $this->getTotalCount($results), + $perPage, + $page, + [ + 'path' => Paginator::resolveCurrentPath(), + 'pageName' => $pageName, + ] + ))->appends('query', $this->query); + } + + /** + * Paginate the given query into a simple paginator with raw data. + */ + public function simplePaginateRaw( + ?int $perPage = null, + string $pageName = 'page', + ?int $page = null + ): Paginator { + $engine = $this->engine(); + + $page = $page ?? Paginator::resolveCurrentPage($pageName); + $perPage = $perPage ?? $this->model->getPerPage(); + + $results = $this->applyAfterRawSearchCallback( + $engine->paginate($this, $perPage, $page) + ); + + return (new Paginator($results, $perPage, $page, [ + 'path' => Paginator::resolveCurrentPath(), + 'pageName' => $pageName, + ]))->hasMorePagesWhen( + ($perPage * $page) < $engine->getTotalCount($results) + )->appends('query', $this->query); + } + + /** + * Get the total number of results from the Scout engine, + * or fallback to query builder. + */ + protected function getTotalCount(mixed $results): int + { + $engine = $this->engine(); + $totalCount = $engine->getTotalCount($results); + + if ($this->queryCallback === null) { + return $totalCount; + } + + $ids = $engine->mapIdsFrom($results, $this->model->getScoutKeyName())->all(); + + if (count($ids) < $totalCount) { + $ids = $engine->keys( + tap(clone $this, function ($builder) use ($totalCount) { + $builder->take( + $this->limit === null ? $totalCount : min($this->limit, $totalCount) + ); + }) + )->all(); + } + + return $this->model->queryScoutModelsByIds($this, $ids) + ->toBase() + ->getCountForPagination(); + } + + /** + * Invoke the "after raw search" callback. + */ + public function applyAfterRawSearchCallback(mixed $results): mixed + { + if ($this->afterRawSearchCallback !== null) { + $results = call_user_func($this->afterRawSearchCallback, $results) ?? $results; + } + + return $results; + } + + /** + * Get the engine that should handle the query. + */ + protected function engine(): Engine + { + return $this->model->searchableUsing(); + } + + /** + * Get the connection type for the underlying model. + */ + public function modelConnectionType(): string + { + /** @var Connection $connection */ + $connection = $this->model->getConnection(); + + return $connection->getDriverName(); + } +} diff --git a/src/scout/src/Console/DeleteAllIndexesCommand.php b/src/scout/src/Console/DeleteAllIndexesCommand.php new file mode 100644 index 000000000..62bc60eb8 --- /dev/null +++ b/src/scout/src/Console/DeleteAllIndexesCommand.php @@ -0,0 +1,53 @@ +engine(); + + if (! method_exists($engine, 'deleteAllIndexes')) { + $driver = $manager->getDefaultDriver(); + + $this->error("The [{$driver}] engine does not support deleting all indexes."); + + return self::FAILURE; + } + + try { + $engine->deleteAllIndexes(); + + $this->info('All indexes deleted successfully.'); + + return self::SUCCESS; + } catch (Exception $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + } +} diff --git a/src/scout/src/Console/DeleteIndexCommand.php b/src/scout/src/Console/DeleteIndexCommand.php new file mode 100644 index 000000000..76d99518f --- /dev/null +++ b/src/scout/src/Console/DeleteIndexCommand.php @@ -0,0 +1,55 @@ +indexName((string) $this->argument('name'), $config); + + $manager->engine()->deleteIndex($name); + + $this->info("Index \"{$name}\" deleted."); + + return self::SUCCESS; + } + + /** + * Get the fully-qualified index name for the given index. + */ + protected function indexName(string $name, ConfigInterface $config): string + { + if (class_exists($name)) { + return (new $name())->indexableAs(); + } + + $prefix = $config->get('scout.prefix', ''); + + return ! Str::startsWith($name, $prefix) ? $prefix . $name : $name; + } +} diff --git a/src/scout/src/Console/FlushCommand.php b/src/scout/src/Console/FlushCommand.php new file mode 100644 index 000000000..6c96fa73d --- /dev/null +++ b/src/scout/src/Console/FlushCommand.php @@ -0,0 +1,62 @@ +resolveModelClass((string) $this->argument('model')); + + $class::removeAllFromSearch(); + + $this->info("All [{$class}] records have been flushed."); + } + + /** + * Resolve the fully-qualified model class name. + * + * @throws ScoutException + */ + protected function resolveModelClass(string $class): string + { + if (class_exists($class)) { + return $class; + } + + // Try the conventional App\Models namespace + $namespacedClass = "App\\Models\\{$class}"; + + if (class_exists($namespacedClass)) { + return $namespacedClass; + } + + throw new ScoutException("Model [{$class}] not found."); + } +} diff --git a/src/scout/src/Console/ImportCommand.php b/src/scout/src/Console/ImportCommand.php new file mode 100644 index 000000000..b64469cb6 --- /dev/null +++ b/src/scout/src/Console/ImportCommand.php @@ -0,0 +1,86 @@ +resolveModelClass((string) $this->argument('model')); + $chunk = $this->option('chunk'); + $fresh = $this->option('fresh'); + + try { + $events->listen(ModelsImported::class, function (ModelsImported $event) use ($class): void { + $lastModel = $event->models->last(); + $key = $lastModel?->getScoutKey(); + + if ($key !== null) { + $this->line("Imported [{$class}] models up to ID: {$key}"); + } + }); + + if ($fresh) { + $class::removeAllFromSearch(); + } + + $class::makeAllSearchable($chunk !== null ? (int) $chunk : null); + } finally { + $class::waitForSearchableJobs(); + $events->forget(ModelsImported::class); + } + + $this->info("All [{$class}] records have been imported."); + } + + /** + * Resolve the fully-qualified model class name. + * + * @throws ScoutException + */ + protected function resolveModelClass(string $class): string + { + if (class_exists($class)) { + return $class; + } + + // Try the conventional App\Models namespace + $namespacedClass = "App\\Models\\{$class}"; + + if (class_exists($namespacedClass)) { + return $namespacedClass; + } + + throw new ScoutException("Model [{$class}] not found."); + } +} diff --git a/src/scout/src/Console/IndexCommand.php b/src/scout/src/Console/IndexCommand.php new file mode 100644 index 000000000..696c49cd3 --- /dev/null +++ b/src/scout/src/Console/IndexCommand.php @@ -0,0 +1,104 @@ +engine(); + + $options = []; + + if ($this->option('key')) { + $options = ['primaryKey' => $this->option('key')]; + } + + $model = null; + $modelName = (string) $this->argument('name'); + + if (class_exists($modelName)) { + $model = new $modelName(); + } + + $name = $this->indexName($modelName, $config); + + $this->createIndex($engine, $name, $options); + + if ($engine instanceof UpdatesIndexSettings) { + $driver = $config->get('scout.driver'); + + $class = $model !== null ? get_class($model) : null; + + $settings = $config->get("scout.{$driver}.index-settings.{$name}") + ?? ($class !== null ? $config->get("scout.{$driver}.index-settings.{$class}") : null) + ?? []; + + if ($model !== null + && $config->get('scout.soft_delete', false) + && in_array(SoftDeletes::class, class_uses_recursive($model))) { + $settings = $engine->configureSoftDeleteFilter($settings); + } + + if ($settings) { + $engine->updateIndexSettings($name, $settings); + } + } + + $this->info("Synchronised index [\"{$name}\"] successfully."); + + return self::SUCCESS; + } + + /** + * Create a search index. + * + * @param array $options + */ + protected function createIndex(Engine $engine, string $name, array $options): void + { + $engine->createIndex($name, $options); + } + + /** + * Get the fully-qualified index name for the given index. + */ + protected function indexName(string $name, ConfigInterface $config): string + { + if (class_exists($name)) { + return (new $name())->indexableAs(); + } + + $prefix = $config->get('scout.prefix', ''); + + return ! Str::startsWith($name, $prefix) ? $prefix . $name : $name; + } +} diff --git a/src/scout/src/Console/SyncIndexSettingsCommand.php b/src/scout/src/Console/SyncIndexSettingsCommand.php new file mode 100644 index 000000000..e55186db2 --- /dev/null +++ b/src/scout/src/Console/SyncIndexSettingsCommand.php @@ -0,0 +1,92 @@ +option('driver') ?: $config->get('scout.driver'); + + $engine = $manager->engine($driver); + + if (! $engine instanceof UpdatesIndexSettings) { + $this->error("The \"{$driver}\" engine does not support updating index settings."); + + return self::FAILURE; + } + + $indexes = (array) $config->get("scout.{$driver}.index-settings", []); + + if (count($indexes) === 0) { + $this->info("No index settings found for the \"{$driver}\" engine."); + + return self::SUCCESS; + } + + foreach ($indexes as $name => $settings) { + if (! is_array($settings)) { + $name = $settings; + $settings = []; + } + + $model = null; + if (class_exists($name)) { + $model = new $name(); + } + + if ($model !== null + && $config->get('scout.soft_delete', false) + && in_array(SoftDeletes::class, class_uses_recursive($model))) { + $settings = $engine->configureSoftDeleteFilter($settings); + } + + $indexName = $this->indexName($name, $config); + $engine->updateIndexSettings($indexName, $settings); + + $this->info("Settings for the [{$indexName}] index synced successfully."); + } + + return self::SUCCESS; + } + + /** + * Get the fully-qualified index name for the given index. + */ + protected function indexName(string $name, ConfigInterface $config): string + { + if (class_exists($name)) { + return (new $name())->indexableAs(); + } + + $prefix = $config->get('scout.prefix', ''); + + return ! Str::startsWith($name, $prefix) ? $prefix . $name : $name; + } +} diff --git a/src/scout/src/Contracts/PaginatesEloquentModels.php b/src/scout/src/Contracts/PaginatesEloquentModels.php new file mode 100644 index 000000000..979e74685 --- /dev/null +++ b/src/scout/src/Contracts/PaginatesEloquentModels.php @@ -0,0 +1,29 @@ + $settings + */ + public function updateIndexSettings(string $name, array $settings = []): void; + + /** + * Configure the soft delete filter within the given settings. + * + * @param array $settings + * @return array + */ + public function configureSoftDeleteFilter(array $settings = []): array; +} diff --git a/src/scout/src/Engine.php b/src/scout/src/Engine.php new file mode 100644 index 000000000..abff2a060 --- /dev/null +++ b/src/scout/src/Engine.php @@ -0,0 +1,125 @@ + $models + */ + abstract public function update(EloquentCollection $models): void; + + /** + * Remove the given models from the search index. + * + * @param EloquentCollection $models + */ + abstract public function delete(EloquentCollection $models): void; + + /** + * Perform a search against the engine. + */ + abstract public function search(Builder $builder): mixed; + + /** + * Perform a paginated search against the engine. + */ + abstract public function paginate(Builder $builder, int $perPage, int $page): mixed; + + /** + * Pluck and return the primary keys of the given results. + */ + abstract public function mapIds(mixed $results): Collection; + + /** + * Map the given results to instances of the given model. + * + * @param Model&SearchableInterface $model + */ + abstract public function map(Builder $builder, mixed $results, Model $model): EloquentCollection; + + /** + * Map the given results to instances of the given model via a lazy collection. + * + * @param Model&SearchableInterface $model + */ + abstract public function lazyMap(Builder $builder, mixed $results, Model $model): LazyCollection; + + /** + * Get the total count from a raw result returned by the engine. + */ + abstract public function getTotalCount(mixed $results): int; + + /** + * Flush all of the model's records from the engine. + * + * @param Model&SearchableInterface $model + */ + abstract public function flush(Model $model): void; + + /** + * Create a search index. + */ + abstract public function createIndex(string $name, array $options = []): mixed; + + /** + * Delete a search index. + */ + abstract public function deleteIndex(string $name): mixed; + + /** + * Pluck and return the primary keys of the given results using the given key name. + */ + public function mapIdsFrom(mixed $results, string $key): Collection + { + return $this->mapIds($results); + } + + /** + * Get the results of the query as a Collection of primary keys. + */ + public function keys(Builder $builder): Collection + { + return $this->mapIds($this->search($builder)); + } + + /** + * Get the results of the given query mapped onto models. + */ + public function get(Builder $builder): EloquentCollection + { + return $this->map( + $builder, + $builder->applyAfterRawSearchCallback($this->search($builder)), + $builder->model + ); + } + + /** + * Get a lazy collection for the given query mapped onto models. + */ + public function cursor(Builder $builder): LazyCollection + { + return $this->lazyMap( + $builder, + $builder->applyAfterRawSearchCallback($this->search($builder)), + $builder->model + ); + } +} diff --git a/src/scout/src/EngineManager.php b/src/scout/src/EngineManager.php new file mode 100644 index 000000000..bc86501a6 --- /dev/null +++ b/src/scout/src/EngineManager.php @@ -0,0 +1,222 @@ + + */ + private static array $engines = []; + + /** + * The registered custom driver creators. + * + * @var array + */ + protected array $customCreators = []; + + /** + * Create a new engine manager instance. + */ + public function __construct( + protected ContainerInterface $container + ) { + } + + /** + * Get an engine instance by name. + */ + public function engine(?string $name = null): Engine + { + $name ??= $this->getDefaultDriver(); + + return self::$engines[$name] ??= $this->resolve($name); + } + + /** + * Resolve the given engine. + * + * @throws InvalidArgumentException + */ + protected function resolve(string $name): Engine + { + if (isset($this->customCreators[$name])) { + return $this->callCustomCreator($name); + } + + $driverMethod = 'create' . ucfirst($name) . 'Driver'; + + if (method_exists($this, $driverMethod)) { + return $this->{$driverMethod}(); + } + + throw new InvalidArgumentException("Driver [{$name}] is not supported."); + } + + /** + * Call a custom driver creator. + */ + protected function callCustomCreator(string $name): Engine + { + return $this->customCreators[$name]($this->container); + } + + /** + * Create a Meilisearch engine instance. + */ + public function createMeilisearchDriver(): MeilisearchEngine + { + $this->ensureMeilisearchClientIsInstalled(); + + return new MeilisearchEngine( + $this->container->get(MeilisearchClient::class), + $this->getConfig('soft_delete', false) + ); + } + + /** + * Ensure the Meilisearch client is installed. + * + * @throws RuntimeException + */ + protected function ensureMeilisearchClientIsInstalled(): void + { + if (class_exists(Meilisearch::class) && version_compare(Meilisearch::VERSION, '1.0.0', '>=')) { + return; + } + + throw new RuntimeException( + 'Please install the Meilisearch client: meilisearch/meilisearch-php (^1.0).' + ); + } + + /** + * Create a Typesense engine instance. + * + * @throws RuntimeException + */ + public function createTypesenseDriver(): TypesenseEngine + { + $this->ensureTypesenseClientIsInstalled(); + + return new TypesenseEngine( + $this->container->get(TypesenseClient::class), + (int) $this->getConfig('typesense.max_total_results', 1000) + ); + } + + /** + * Ensure the Typesense client is installed. + * + * @throws RuntimeException + */ + protected function ensureTypesenseClientIsInstalled(): void + { + if (class_exists(TypesenseClient::class)) { + return; + } + + throw new RuntimeException( + 'Please install the Typesense client: typesense/typesense-php.' + ); + } + + /** + * Create a collection engine instance. + */ + public function createCollectionDriver(): CollectionEngine + { + return new CollectionEngine(); + } + + /** + * Create a database engine instance. + */ + public function createDatabaseDriver(): DatabaseEngine + { + return new DatabaseEngine(); + } + + /** + * Create a null engine instance. + */ + public function createNullDriver(): NullEngine + { + return new NullEngine(); + } + + /** + * Register a custom driver creator. + */ + public function extend(string $driver, Closure $callback): static + { + $this->customCreators[$driver] = $callback; + + return $this; + } + + /** + * Forget all of the resolved engine instances. + * + * Primarily useful for testing. + */ + public function forgetEngines(): static + { + self::$engines = []; + + return $this; + } + + /** + * Forget a specific resolved engine instance. + */ + public function forgetEngine(string $name): static + { + unset(self::$engines[$name]); + + return $this; + } + + /** + * Get the default Scout driver name. + */ + public function getDefaultDriver(): string + { + $driver = $this->getConfig('driver'); + + return $driver ?? 'null'; + } + + /** + * Get a Scout configuration value. + */ + protected function getConfig(string $key, mixed $default = null): mixed + { + return $this->container->get(ConfigInterface::class)->get("scout.{$key}", $default); + } +} diff --git a/src/scout/src/Engines/CollectionEngine.php b/src/scout/src/Engines/CollectionEngine.php new file mode 100644 index 000000000..33c33638e --- /dev/null +++ b/src/scout/src/Engines/CollectionEngine.php @@ -0,0 +1,301 @@ +, total: int} + */ + public function search(Builder $builder): mixed + { + $models = $this->searchModels($builder); + + if ($builder->limit !== null) { + $models = $models->take($builder->limit); + } + + return [ + 'results' => $models->all(), + 'total' => count($models), + ]; + } + + /** + * Perform a paginated search against the engine. + * + * @return array{results: array, total: int} + */ + public function paginate(Builder $builder, int $perPage, int $page): mixed + { + $models = $this->searchModels($builder); + + return [ + 'results' => $models->forPage($page, $perPage)->all(), + 'total' => count($models), + ]; + } + + /** + * Get the Eloquent models for the given builder. + */ + protected function searchModels(Builder $builder): EloquentCollection + { + $query = $builder->model->query() + ->when($builder->callback !== null, function ($query) use ($builder) { + call_user_func($builder->callback, $query, $builder, $builder->query); + }) + ->when($builder->callback === null && count($builder->wheres) > 0, function ($query) use ($builder) { + foreach ($builder->wheres as $key => $value) { + if ($key !== '__soft_deleted') { + $query->where($key, $value); + } + } + }) + ->when($builder->callback === null && count($builder->whereIns) > 0, function ($query) use ($builder) { + foreach ($builder->whereIns as $key => $values) { + $query->whereIn($key, $values); + } + }) + ->when($builder->callback === null && count($builder->whereNotIns) > 0, function ($query) use ($builder) { + foreach ($builder->whereNotIns as $key => $values) { + $query->whereNotIn($key, $values); + } + }) + ->when(count($builder->orders) > 0, function ($query) use ($builder) { + foreach ($builder->orders as $order) { + $query->orderBy($order['column'], $order['direction']); + } + }, function (EloquentBuilder $query) use ($builder) { + $query->orderBy( + $builder->model->qualifyColumn($builder->model->getScoutKeyName()), + 'desc' + ); + }); + + /** @var EloquentCollection $models */ + $models = $this->ensureSoftDeletesAreHandled($builder, $query) + ->get() + ->values(); + + if ($models->isEmpty()) { + return $models; + } + + /** @var Model&SearchableInterface $firstModel */ + $firstModel = $models->first(); + + /** @var EloquentCollection $searchableModels */ + $searchableModels = $firstModel->makeSearchableUsing($models); + + return $searchableModels + ->filter(function ($model) use ($builder) { + /** @var Model&SearchableInterface $model */ + if (! $model->shouldBeSearchable()) { + return false; + } + + if (! $builder->query) { + return true; + } + + $searchables = $model->toSearchableArray(); + + foreach ($searchables as $value) { + if (! is_scalar($value)) { + $value = json_encode($value); + } + + if (Str::contains(Str::lower((string) $value), Str::lower($builder->query))) { + return true; + } + } + + return false; + }) + ->values(); + } + + /** + * Ensure that soft delete handling is properly applied to the query. + * + * The withTrashed/onlyTrashed/withoutTrashed methods are added dynamically + * by SoftDeletingScope. We guard these calls with runtime checks for SoftDeletes + * usage, making them safe but not statically analyzable. + */ + protected function ensureSoftDeletesAreHandled(Builder $builder, EloquentBuilder $query): EloquentBuilder + { + if (Arr::get($builder->wheres, '__soft_deleted') === 0) { + /* @phpstan-ignore method.notFound (SoftDeletingScope adds this method) */ + return $query->withoutTrashed(); + } + + if (Arr::get($builder->wheres, '__soft_deleted') === 1) { + /* @phpstan-ignore method.notFound (SoftDeletingScope adds this method) */ + return $query->onlyTrashed(); + } + + if (in_array(SoftDeletes::class, class_uses_recursive(get_class($builder->model))) + && $this->getScoutConfig('soft_delete', false) + ) { + /* @phpstan-ignore method.notFound (SoftDeletingScope adds this method) */ + return $query->withTrashed(); + } + + return $query; + } + + /** + * Pluck and return the primary keys of the given results. + */ + public function mapIds(mixed $results): Collection + { + /** @var array $resultModels */ + $resultModels = array_values($results['results']); + + if (count($resultModels) === 0) { + return collect(); + } + + return collect($resultModels)->pluck($resultModels[0]->getScoutKeyName()); + } + + /** + * Map the given results to instances of the given model. + * + * @param Model&SearchableInterface $model + */ + public function map(Builder $builder, mixed $results, Model $model): EloquentCollection + { + $results = $results['results']; + + if (count($results) === 0) { + return $model->newCollection(); + } + + $objectIds = collect($results) + ->pluck($model->getScoutKeyName()) + ->values() + ->all(); + + $objectIdPositions = array_flip($objectIds); + + /** @var EloquentCollection $scoutModels */ + $scoutModels = $model->getScoutModelsByIds($builder, $objectIds); + + return $scoutModels + ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds)) + ->sortBy(fn ($m) => $objectIdPositions[$m->getScoutKey()]) + ->values(); + } + + /** + * Map the given results to instances of the given model via a lazy collection. + * + * @param Model&SearchableInterface $model + */ + public function lazyMap(Builder $builder, mixed $results, Model $model): LazyCollection + { + $results = $results['results']; + + if (count($results) === 0) { + return LazyCollection::empty(); + } + + $objectIds = collect($results) + ->pluck($model->getScoutKeyName()) + ->values() + ->all(); + + $objectIdPositions = array_flip($objectIds); + + /** @var LazyCollection $cursor */ + $cursor = $model->queryScoutModelsByIds($builder, $objectIds)->cursor(); + + return $cursor + ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds)) + ->sortBy(fn ($m) => $objectIdPositions[$m->getScoutKey()]) + ->values(); + } + + /** + * Get the total count from a raw result returned by the engine. + */ + public function getTotalCount(mixed $results): int + { + return $results['total']; + } + + /** + * Flush all of the model's records from the engine. + */ + public function flush(Model $model): void + { + // No-op - data lives in the database + } + + /** + * Create a search index. + */ + public function createIndex(string $name, array $options = []): mixed + { + return null; + } + + /** + * Delete a search index. + */ + public function deleteIndex(string $name): mixed + { + return null; + } + + /** + * Get a Scout configuration value. + */ + protected function getScoutConfig(string $key, mixed $default = null): mixed + { + return ApplicationContext::getContainer() + ->get(ConfigInterface::class) + ->get("scout.{$key}", $default); + } +} diff --git a/src/scout/src/Engines/DatabaseEngine.php b/src/scout/src/Engines/DatabaseEngine.php new file mode 100644 index 000000000..30755a0b9 --- /dev/null +++ b/src/scout/src/Engines/DatabaseEngine.php @@ -0,0 +1,512 @@ +, total: int} + */ + public function search(Builder $builder): array + { + $models = $this->searchModels($builder); + + return [ + 'results' => $models, + 'total' => $models->count(), + ]; + } + + /** + * Perform a paginated search against the engine. + * + * @return array{results: EloquentCollection, total: int} + */ + public function paginate(Builder $builder, int $perPage, int $page): array + { + $models = $this->searchModels($builder, $page, $perPage); + + return [ + 'results' => $models, + 'total' => $this->buildSearchQuery($builder)->count(), + ]; + } + + /** + * Paginate the given search on the engine using database pagination. + */ + public function paginateUsingDatabase( + Builder $builder, + int $perPage, + string $pageName, + int $page + ): LengthAwarePaginatorInterface { + return $this->buildSearchQuery($builder) + ->when(count($builder->orders) > 0, function (EloquentBuilder $query) use ($builder): void { + foreach ($builder->orders as $order) { + $query->orderBy($order['column'], $order['direction']); + } + }) + ->when(count($this->getFullTextColumns($builder)) === 0, function (EloquentBuilder $query) use ($builder): void { + $query->orderBy( + $builder->model->getTable() . '.' . $builder->model->getScoutKeyName(), + 'desc' + ); + }) + ->when($this->shouldOrderByRelevance($builder), function (EloquentBuilder $query) use ($builder): void { + $this->orderByRelevance($builder, $query); + }) + ->paginate($perPage, ['*'], $pageName, $page); + } + + /** + * Paginate the given search on the engine using simple database pagination. + */ + public function simplePaginateUsingDatabase( + Builder $builder, + int $perPage, + string $pageName, + int $page + ): PaginatorInterface { + return $this->buildSearchQuery($builder) + ->when(count($builder->orders) > 0, function (EloquentBuilder $query) use ($builder): void { + foreach ($builder->orders as $order) { + $query->orderBy($order['column'], $order['direction']); + } + }) + ->when(count($this->getFullTextColumns($builder)) === 0, function (EloquentBuilder $query) use ($builder): void { + $query->orderBy( + $builder->model->getTable() . '.' . $builder->model->getScoutKeyName(), + 'desc' + ); + }) + ->when($this->shouldOrderByRelevance($builder), function (EloquentBuilder $query) use ($builder): void { + $this->orderByRelevance($builder, $query); + }) + ->simplePaginate($perPage, ['*'], $pageName, $page); + } + + /** + * Get the Eloquent models for the given builder. + * + * @return EloquentCollection + */ + protected function searchModels(Builder $builder, ?int $page = null, ?int $perPage = null): EloquentCollection + { + /** @var EloquentCollection */ + return $this->buildSearchQuery($builder) + ->when($page !== null && $perPage !== null, function (EloquentBuilder $query) use ($page, $perPage): void { + $query->forPage($page, $perPage); + }) + ->when(count($builder->orders) > 0, function (EloquentBuilder $query) use ($builder): void { + foreach ($builder->orders as $order) { + $query->orderBy($order['column'], $order['direction']); + } + }) + ->when(count($this->getFullTextColumns($builder)) === 0, function (EloquentBuilder $query) use ($builder): void { + $query->orderBy( + $builder->model->getTable() . '.' . $builder->model->getScoutKeyName(), + 'desc' + ); + }) + ->when($this->shouldOrderByRelevance($builder), function (EloquentBuilder $query) use ($builder): void { + $this->orderByRelevance($builder, $query); + }) + ->get(); + } + + /** + * Initialize / build the search query for the given Scout builder. + */ + protected function buildSearchQuery(Builder $builder): EloquentBuilder + { + $query = $this->initializeSearchQuery( + $builder, + array_keys($builder->model->toSearchableArray()), + $this->getPrefixColumns($builder), + $this->getFullTextColumns($builder) + ); + + $queryWithLimit = $builder->limit !== null ? $query->take($builder->limit) : $query; + + return $this->constrainForSoftDeletes( + $builder, + $this->addAdditionalConstraints($builder, $queryWithLimit) + ); + } + + /** + * Build the initial text search database query for all relevant columns. + * + * @param array $columns + * @param array $prefixColumns + * @param array $fullTextColumns + */ + protected function initializeSearchQuery( + Builder $builder, + array $columns, + array $prefixColumns = [], + array $fullTextColumns = [] + ): EloquentBuilder { + $query = method_exists($builder->model, 'newScoutQuery') + ? $builder->model->newScoutQuery($builder) + : $builder->model->newQuery(); + + if (blank($builder->query)) { + return $query; + } + + $connectionType = $builder->modelConnectionType(); + + return $query->where(function (EloquentBuilder $query) use ($connectionType, $builder, $columns, $prefixColumns, $fullTextColumns): void { + $canSearchPrimaryKey = ctype_digit((string) $builder->query) + && in_array($builder->model->getKeyType(), ['int', 'integer']) + && ($connectionType !== 'pgsql' || (int) $builder->query <= PHP_INT_MAX) + && in_array($builder->model->getScoutKeyName(), $columns); + + if ($canSearchPrimaryKey) { + $query->orWhere($builder->model->getQualifiedKeyName(), $builder->query); + } + + $likeOperator = $connectionType === 'pgsql' ? 'ilike' : 'like'; + + foreach ($columns as $column) { + if (in_array($column, $fullTextColumns)) { + continue; + } + + if ($canSearchPrimaryKey && $column === $builder->model->getScoutKeyName()) { + continue; + } + + $pattern = in_array($column, $prefixColumns) + ? $builder->query . '%' + : '%' . $builder->query . '%'; + + $query->orWhere( + $builder->model->qualifyColumn($column), + $likeOperator, + $pattern + ); + } + + if (count($fullTextColumns) > 0) { + $qualifiedColumns = array_map( + fn (string $column): string => $builder->model->qualifyColumn($column), + $fullTextColumns + ); + + $query->orWhereFullText( + $qualifiedColumns, + $builder->query, + $this->getFullTextOptions($builder) + ); + } + }); + } + + /** + * Determine if the query should be ordered by relevance. + */ + protected function shouldOrderByRelevance(Builder $builder): bool + { + // MySQL orders by relevance by default, so we will only order by relevance on + // Postgres with no developer-defined orders. + return $builder->modelConnectionType() === 'pgsql' + && count($this->getFullTextColumns($builder)) > 0 + && empty($builder->orders); + } + + /** + * Add an "order by" clause that orders by relevance (Postgres only). + */ + protected function orderByRelevance(Builder $builder, EloquentBuilder $query): EloquentBuilder + { + $fullTextColumns = $this->getFullTextColumns($builder); + $options = $this->getFullTextOptions($builder); + $language = $options['language'] ?? 'english'; + + $vectors = collect($fullTextColumns) + ->map(fn (string $column): string => sprintf( + "to_tsvector('%s', %s)", + $language, + $builder->model->qualifyColumn($column) + )) + ->implode(' || '); + + $tsQueryFunction = match ($options['mode'] ?? 'plainto_tsquery') { + 'phrase' => 'phraseto_tsquery', + 'websearch' => 'websearch_to_tsquery', + default => 'plainto_tsquery', + }; + + $query->orderByRaw( + sprintf('ts_rank(%s, %s(?)) desc', $vectors, $tsQueryFunction), + [$builder->query] + ); + + return $query; + } + + /** + * Add additional, developer defined constraints to the search query. + */ + protected function addAdditionalConstraints(Builder $builder, EloquentBuilder $query): EloquentBuilder + { + return $query + ->when($builder->callback !== null, function (EloquentBuilder $query) use ($builder): void { + call_user_func($builder->callback, $query, $builder, $builder->query); + }) + ->when($builder->callback === null && count($builder->wheres) > 0, function (EloquentBuilder $query) use ($builder): void { + foreach ($builder->wheres as $key => $value) { + if ($key !== '__soft_deleted') { + $query->where($key, '=', $value); + } + } + }) + ->when($builder->callback === null && count($builder->whereIns) > 0, function (EloquentBuilder $query) use ($builder): void { + foreach ($builder->whereIns as $key => $values) { + $query->whereIn($key, $values); + } + }) + ->when($builder->callback === null && count($builder->whereNotIns) > 0, function (EloquentBuilder $query) use ($builder): void { + foreach ($builder->whereNotIns as $key => $values) { + $query->whereNotIn($key, $values); + } + }) + ->when($builder->queryCallback !== null, function (EloquentBuilder $query) use ($builder): void { + call_user_func($builder->queryCallback, $query); + }); + } + + /** + * Ensure that soft delete constraints are properly applied to the query. + */ + protected function constrainForSoftDeletes(Builder $builder, EloquentBuilder $query): EloquentBuilder + { + $softDeletedValue = Arr::get($builder->wheres, '__soft_deleted'); + + if ($softDeletedValue === 0) { + /* @phpstan-ignore method.notFound (SoftDeletes adds this method via global scope) */ + return $query->withoutTrashed(); + } + + if ($softDeletedValue === 1) { + /* @phpstan-ignore method.notFound (SoftDeletes adds this method via global scope) */ + return $query->onlyTrashed(); + } + + $usesSoftDeletes = in_array( + SoftDeletes::class, + class_uses_recursive(get_class($builder->model)) + ); + + if ($usesSoftDeletes && $this->getConfig('soft_delete', false)) { + /* @phpstan-ignore method.notFound (SoftDeletes adds this method via global scope) */ + return $query->withTrashed(); + } + + return $query; + } + + /** + * Get the full-text columns for the query. + * + * @return array + */ + protected function getFullTextColumns(Builder $builder): array + { + return $this->getAttributeColumns($builder, SearchUsingFullText::class); + } + + /** + * Get the prefix search columns for the query. + * + * @return array + */ + protected function getPrefixColumns(Builder $builder): array + { + return $this->getAttributeColumns($builder, SearchUsingPrefix::class); + } + + /** + * Get the columns marked with a given attribute. + * + * @param class-string $attributeClass + * @return array + */ + protected function getAttributeColumns(Builder $builder, string $attributeClass): array + { + $columns = []; + + $reflection = new ReflectionMethod($builder->model, 'toSearchableArray'); + + foreach ($reflection->getAttributes() as $attribute) { + if ($attribute->getName() !== $attributeClass) { + continue; + } + + $columns = array_merge($columns, Arr::wrap($attribute->getArguments()[0])); + } + + return $columns; + } + + /** + * Get the full-text search options for the query. + * + * @return array + */ + protected function getFullTextOptions(Builder $builder): array + { + $options = []; + + $reflection = new ReflectionMethod($builder->model, 'toSearchableArray'); + + foreach ($reflection->getAttributes(SearchUsingFullText::class) as $attribute) { + $arguments = $attribute->getArguments()[1] ?? []; + $options = array_merge($options, Arr::wrap($arguments)); + } + + return $options; + } + + /** + * Pluck and return the primary keys of the given results. + */ + public function mapIds(mixed $results): Collection + { + /** @var EloquentCollection $collection */ + $collection = $results['results']; + + return $collection->isNotEmpty() + ? collect($collection->modelKeys()) + : collect(); + } + + /** + * Map the given results to instances of the given model. + * + * @param Model&SearchableInterface $model + * @return EloquentCollection + */ + public function map(Builder $builder, mixed $results, Model $model): EloquentCollection + { + return $results['results']; + } + + /** + * Map the given results to instances of the given model via a lazy collection. + * + * @param Model&SearchableInterface $model + */ + public function lazyMap(Builder $builder, mixed $results, Model $model): LazyCollection + { + /** @var EloquentCollection $collection */ + $collection = $results['results']; + + return new LazyCollection($collection->all()); + } + + /** + * Get the total count from a raw result returned by the engine. + */ + public function getTotalCount(mixed $results): int + { + return (int) $results['total']; + } + + /** + * Update the given models in the search index. + * + * The database engine doesn't need to update an external index. + * + * @param EloquentCollection $models + */ + public function update(EloquentCollection $models): void + { + // No-op: The database is the index. + } + + /** + * Remove the given models from the search index. + * + * The database engine doesn't need to remove from an external index. + * + * @param EloquentCollection $models + */ + public function delete(EloquentCollection $models): void + { + // No-op: The database is the index. + } + + /** + * Flush all of the model's records from the engine. + * + * @param Model&SearchableInterface $model + */ + public function flush(Model $model): void + { + // No-op: The database is the index. + } + + /** + * Create a search index. + */ + public function createIndex(string $name, array $options = []): mixed + { + // No-op: The database table is the index. + return null; + } + + /** + * Delete a search index. + */ + public function deleteIndex(string $name): mixed + { + // No-op: The database table is the index. + return null; + } + + /** + * Get a Scout configuration value. + */ + protected function getConfig(string $key, mixed $default = null): mixed + { + return ApplicationContext::getContainer() + ->get(ConfigInterface::class) + ->get("scout.{$key}", $default); + } +} diff --git a/src/scout/src/Engines/MeilisearchEngine.php b/src/scout/src/Engines/MeilisearchEngine.php new file mode 100644 index 000000000..184c84ccc --- /dev/null +++ b/src/scout/src/Engines/MeilisearchEngine.php @@ -0,0 +1,498 @@ + $models + * @throws ApiException + */ + public function update(EloquentCollection $models): void + { + if ($models->isEmpty()) { + return; + } + + /** @var Model&SearchableInterface $firstModel */ + $firstModel = $models->first(); + $index = $this->meilisearch->index($firstModel->indexableAs()); + + if ($this->usesSoftDelete($firstModel) && $this->softDelete) { + $models->each->pushSoftDeleteMetadata(); + } + + $objects = $models->map(function (Model $model) { + /** @var Model&SearchableInterface $model */ + $searchableData = $model->toSearchableArray(); + + if (empty($searchableData)) { + return null; + } + + return array_merge( + $searchableData, + $model->scoutMetadata(), + [$model->getScoutKeyName() => $model->getScoutKey()], + ); + }) + ->filter() + ->values() + ->all(); + + if (! empty($objects)) { + $index->addDocuments($objects, $firstModel->getScoutKeyName()); + } + } + + /** + * Remove the given models from the search index. + * + * @param EloquentCollection $models + */ + public function delete(EloquentCollection $models): void + { + if ($models->isEmpty()) { + return; + } + + /** @var Model&SearchableInterface $firstModel */ + $firstModel = $models->first(); + $index = $this->meilisearch->index($firstModel->indexableAs()); + + $keys = $models->map(fn (SearchableInterface $model) => $model->getScoutKey())->values()->all(); + + $index->deleteDocuments($keys); + } + + /** + * Perform a search against the engine. + */ + public function search(Builder $builder): mixed + { + return $this->performSearch($builder, array_filter([ + 'filter' => $this->filters($builder), + 'hitsPerPage' => $builder->limit, + 'sort' => $this->buildSortFromOrderByClauses($builder), + ])); + } + + /** + * Perform a paginated search against the engine. + */ + public function paginate(Builder $builder, int $perPage, int $page): mixed + { + return $this->performSearch($builder, array_filter([ + 'filter' => $this->filters($builder), + 'hitsPerPage' => $perPage, + 'page' => $page, + 'sort' => $this->buildSortFromOrderByClauses($builder), + ])); + } + + /** + * Perform the given search on the engine. + */ + protected function performSearch(Builder $builder, array $searchParams = []): mixed + { + $meilisearch = $this->meilisearch->index($builder->index ?? $builder->model->searchableAs()); + + $searchParams = array_merge($builder->options, $searchParams); + + if (array_key_exists('attributesToRetrieve', $searchParams)) { + $searchParams['attributesToRetrieve'] = array_merge( + [$builder->model->getScoutKeyName()], + $searchParams['attributesToRetrieve'], + ); + } + + if ($builder->callback !== null) { + $result = call_user_func( + $builder->callback, + $meilisearch, + $builder->query, + $searchParams + ); + + return $result instanceof SearchResult ? $result->getRaw() : $result; + } + + return $meilisearch->rawSearch($builder->query, $searchParams); + } + + /** + * Get the filter string for the query. + */ + protected function filters(Builder $builder): string + { + $filters = collect($builder->wheres) + ->map(function ($value, $key) { + if (is_bool($value)) { + return sprintf('%s=%s', $key, $value ? 'true' : 'false'); + } + + if ($value === null) { + return sprintf('%s IS NULL', $key); + } + + return is_numeric($value) + ? sprintf('%s=%s', $key, $value) + : sprintf('%s="%s"', $key, $value); + }); + + $whereInOperators = [ + 'whereIns' => 'IN', + 'whereNotIns' => 'NOT IN', + ]; + + foreach ($whereInOperators as $property => $operator) { + foreach ($builder->{$property} as $key => $values) { + $filters->push(sprintf( + '%s %s [%s]', + $key, + $operator, + collect($values)->map(function ($value) { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + return filter_var($value, FILTER_VALIDATE_INT) !== false + ? (string) $value + : sprintf('"%s"', $value); + })->implode(', ') + )); + } + } + + return $filters->values()->implode(' AND '); + } + + /** + * Get the sort array for the query. + * + * @return array + */ + protected function buildSortFromOrderByClauses(Builder $builder): array + { + return collect($builder->orders) + ->map(fn (array $order) => $order['column'] . ':' . $order['direction']) + ->toArray(); + } + + /** + * Pluck and return the primary keys of the given results. + */ + public function mapIds(mixed $results): Collection + { + if (count($results['hits']) === 0) { + return collect(); + } + + $hits = collect($results['hits']); + $key = key($hits->first()); + + return $hits->pluck($key)->values(); + } + + /** + * Pluck the given results with the given primary key name. + */ + public function mapIdsFrom(mixed $results, string $key): Collection + { + return count($results['hits']) === 0 + ? collect() + : collect($results['hits'])->pluck($key)->values(); + } + + /** + * Get the results of the query as a Collection of primary keys. + */ + public function keys(Builder $builder): Collection + { + $scoutKey = $builder->model->getScoutKeyName(); + + return $this->mapIdsFrom($this->search($builder), $scoutKey); + } + + /** + * Map the given results to instances of the given model. + * + * @param Model&SearchableInterface $model + */ + public function map(Builder $builder, mixed $results, Model $model): EloquentCollection + { + if ($results === null || count($results['hits']) === 0) { + return $model->newCollection(); + } + + $objectIds = collect($results['hits']) + ->pluck($model->getScoutKeyName()) + ->values() + ->all(); + + $objectIdPositions = array_flip($objectIds); + + /** @var EloquentCollection $scoutModels */ + $scoutModels = $model->getScoutModelsByIds($builder, $objectIds); + + return $scoutModels + ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds)) + ->map(function ($m) use ($results, $objectIdPositions) { + /** @var Model&SearchableInterface $m */ + $result = $results['hits'][$objectIdPositions[$m->getScoutKey()]] ?? []; + + foreach ($result as $key => $value) { + if (str_starts_with($key, '_')) { + $m->withScoutMetadata($key, $value); + } + } + + return $m; + }) + ->sortBy(fn ($m) => $objectIdPositions[$m->getScoutKey()]) + ->values(); + } + + /** + * Map the given results to instances of the given model via a lazy collection. + * + * @param Model&SearchableInterface $model + */ + public function lazyMap(Builder $builder, mixed $results, Model $model): LazyCollection + { + if (count($results['hits']) === 0) { + return LazyCollection::empty(); + } + + $objectIds = collect($results['hits']) + ->pluck($model->getScoutKeyName()) + ->values() + ->all(); + + $objectIdPositions = array_flip($objectIds); + + /** @var LazyCollection $cursor */ + $cursor = $model->queryScoutModelsByIds($builder, $objectIds)->cursor(); + + return $cursor + ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds)) + ->map(function ($m) use ($results, $objectIdPositions) { + /** @var Model&SearchableInterface $m */ + $result = $results['hits'][$objectIdPositions[$m->getScoutKey()]] ?? []; + + foreach ($result as $key => $value) { + if (str_starts_with($key, '_')) { + $m->withScoutMetadata($key, $value); + } + } + + return $m; + }) + ->sortBy(fn ($m) => $objectIdPositions[$m->getScoutKey()]) + ->values(); + } + + /** + * Get the total count from a raw result returned by the engine. + */ + public function getTotalCount(mixed $results): int + { + return $results['totalHits'] ?? $results['estimatedTotalHits'] ?? 0; + } + + /** + * Flush all of the model's records from the engine. + * + * @param Model&SearchableInterface $model + */ + public function flush(Model $model): void + { + $index = $this->meilisearch->index($model->indexableAs()); + + $index->deleteAllDocuments(); + } + + /** + * Create a search index. + * + * @throws ApiException + */ + public function createIndex(string $name, array $options = []): mixed + { + try { + $index = $this->meilisearch->getIndex($name); + } catch (ApiException) { + $index = null; + } + + if ($index?->getUid() !== null) { + return $index; + } + + return $this->meilisearch->createIndex($name, $options); + } + + /** + * Update the index settings for the given index. + */ + public function updateIndexSettings(string $name, array $settings = []): void + { + $index = $this->meilisearch->index($name); + + $index->updateSettings(Arr::except($settings, 'embedders')); + + if (! empty($settings['embedders'])) { + $index->updateEmbedders($settings['embedders']); + } + } + + /** + * Configure the soft delete filter within the given settings. + * + * @return array + */ + public function configureSoftDeleteFilter(array $settings = []): array + { + $settings['filterableAttributes'][] = '__soft_deleted'; + + return $settings; + } + + /** + * Delete a search index. + * + * @throws ApiException + */ + public function deleteIndex(string $name): mixed + { + return $this->meilisearch->deleteIndex($name); + } + + /** + * Delete all search indexes. + * + * @return array + */ + public function deleteAllIndexes(): array + { + $tasks = []; + $limit = 1000000; + + $query = new IndexesQuery(); + $query->setLimit($limit); + + $indexes = $this->meilisearch->getIndexes($query); + + foreach ($indexes->getResults() as $index) { + $tasks[] = $index->delete(); + } + + return $tasks; + } + + /** + * Generate a tenant token for frontend direct search. + * + * Tenant tokens allow secure, scoped searches directly from the frontend + * without exposing the admin API key. All tenants share a single index, + * with data isolation enforced at query time via embedded filters. + * + * @param array $searchRules Rules per index + * + * @see https://www.meilisearch.com/blog/multi-tenancy-guide + */ + public function generateTenantToken( + array $searchRules, + ?string $apiKeyUid = null, + ?DateTimeImmutable $expiresAt = null + ): string { + return $this->meilisearch->generateTenantToken( + $apiKeyUid ?? $this->getDefaultApiKeyUid(), + $searchRules, + [ + 'expiresAt' => $expiresAt, + ] + ); + } + + /** + * Get the default API key UID for tenant token generation. + */ + protected function getDefaultApiKeyUid(): string + { + // The API key's UID is typically the first 8 chars of the key + // This should be configured or retrieved from Meilisearch + $keys = $this->meilisearch->getKeys(); + + /** @var array}> $results */ + $results = $keys->getResults(); + + foreach ($results as $key) { + $actions = $key['actions'] ?? []; + if (in_array('search', $actions) || in_array('*', $actions)) { + return $key['uid'] ?? ''; + } + } + + throw new RuntimeException('No valid API key found for tenant token generation.'); + } + + /** + * Determine if the given model uses soft deletes. + */ + protected function usesSoftDelete(Model $model): bool + { + return in_array(SoftDeletes::class, class_uses_recursive($model)); + } + + /** + * Get the underlying Meilisearch client. + */ + public function getMeilisearchClient(): MeilisearchClient + { + return $this->meilisearch; + } + + /** + * Dynamically call the Meilisearch client instance. + */ + public function __call(string $method, array $parameters): mixed + { + return $this->meilisearch->{$method}(...$parameters); + } +} diff --git a/src/scout/src/Engines/NullEngine.php b/src/scout/src/Engines/NullEngine.php new file mode 100644 index 000000000..b14efdb01 --- /dev/null +++ b/src/scout/src/Engines/NullEngine.php @@ -0,0 +1,108 @@ + + */ + protected array $searchParameters = []; + + /** + * The maximum number of results that can be fetched per page. + */ + private int $maxPerPage = 250; + + /** + * Create a new TypesenseEngine instance. + */ + public function __construct( + protected Typesense $typesense, + protected int $maxTotalResults + ) { + } + + /** + * Update the given models in the search index. + * + * @param EloquentCollection $models + * @throws TypesenseClientError + */ + public function update(EloquentCollection $models): void + { + if ($models->isEmpty()) { + return; + } + + /** @var Model&SearchableInterface $firstModel */ + $firstModel = $models->first(); + + $collection = $this->getOrCreateCollectionFromModel($firstModel); + + if ($this->usesSoftDelete($firstModel) && $this->getConfig('soft_delete', false)) { + $models->each->pushSoftDeleteMetadata(); + } + + $objects = $models->map(function (Model $model): ?array { + /** @var Model&SearchableInterface $model */ + $searchableData = $model->toSearchableArray(); + + if (empty($searchableData)) { + return null; + } + + return array_merge( + $searchableData, + $model->scoutMetadata(), + ); + }) + ->filter() + ->values() + ->all(); + + if (! empty($objects)) { + $this->importDocuments($collection, $objects); + } + } + + /** + * Import the given documents into the index. + * + * @param array> $documents + * @return Collection + * @throws TypesenseClientError + */ + protected function importDocuments( + TypesenseCollection $collectionIndex, + array $documents, + ?string $action = null + ): Collection { + $action = $action ?? $this->getConfig('typesense.import_action', 'upsert'); + + /** @var array $importedDocuments */ + $importedDocuments = $collectionIndex->getDocuments()->import($documents, ['action' => $action]); + + $results = []; + + foreach ($importedDocuments as $importedDocument) { + if (! $importedDocument['success']) { + throw new TypesenseClientError("Error importing document: {$importedDocument['error']}"); + } + + $results[] = $this->createImportSortingDataObject($importedDocument); + } + + return collect($results); + } + + /** + * Create an import sorting data object for a given document. + * + * @param array{success: bool, error?: string, code?: int, document?: string} $document + */ + protected function createImportSortingDataObject(array $document): stdClass + { + $data = new stdClass(); + + $data->code = $document['code'] ?? 0; + $data->success = $document['success']; + $data->error = $document['error'] ?? null; + $data->document = json_decode($document['document'] ?? '[]', true, 512, JSON_THROW_ON_ERROR); + + return $data; + } + + /** + * Remove the given models from the search index. + * + * @param EloquentCollection $models + * @throws TypesenseClientError + */ + public function delete(EloquentCollection $models): void + { + $models->each(function (Model $model): void { + /** @var Model&SearchableInterface $model */ + $this->deleteDocument( + $this->getOrCreateCollectionFromModel($model, null, false), + $model->getScoutKey() + ); + }); + } + + /** + * Delete a document from the index. + * + * Returns an empty array if the document doesn't exist (idempotent delete). + * Other errors (network, auth, etc.) are allowed to bubble up. + * + * @return array + * @throws TypesenseClientError + */ + protected function deleteDocument(TypesenseCollection $collectionIndex, mixed $modelId): array + { + $document = $collectionIndex->getDocuments()[(string) $modelId]; + + try { + $document->retrieve(); + + return $document->delete(); + } catch (ObjectNotFound) { + // Document already gone, nothing to delete + return []; + } + } + + /** + * Perform a search against the engine. + * + * @throws TypesenseClientError + */ + public function search(Builder $builder): mixed + { + // If the limit exceeds Typesense's capabilities, perform a paginated search + if ($builder->limit !== null && $builder->limit >= $this->maxPerPage) { + return $this->performPaginatedSearch($builder); + } + + // Cap per_page by both maxPerPage (Typesense limit) and maxTotalResults (config limit) + $perPage = min($builder->limit ?? $this->maxPerPage, $this->maxPerPage, $this->maxTotalResults); + + return $this->performSearch( + $builder, + $this->buildSearchParameters($builder, 1, $perPage) + ); + } + + /** + * Perform a paginated search against the engine. + * + * @throws TypesenseClientError + */ + public function paginate(Builder $builder, int $perPage, int $page): mixed + { + $maxInt = 4294967295; + + $page = max(1, $page); + $perPage = max(1, min($perPage, $this->maxPerPage, $this->maxTotalResults)); + + if ($page * $perPage > $maxInt) { + $page = (int) floor($maxInt / $perPage); + } + + return $this->performSearch( + $builder, + $this->buildSearchParameters($builder, $page, $perPage) + ); + } + + /** + * Perform the given search on the engine. + * + * @param array $options + * @throws TypesenseClientError + */ + protected function performSearch(Builder $builder, array $options = []): mixed + { + $documents = $this->getOrCreateCollectionFromModel( + $builder->model, + $builder->index, + false, + )->getDocuments(); + + if ($builder->callback !== null) { + return call_user_func($builder->callback, $documents, $builder->query, $options); + } + + try { + return $documents->search($options); + } catch (ObjectNotFound) { + $this->getOrCreateCollectionFromModel($builder->model, $builder->index, true); + + return $documents->search($options); + } + } + + /** + * Perform a paginated search on the engine. + * + * @return array{hits: array, found: int, out_of: int, page: int, request_params: array} + * @throws TypesenseClientError + */ + protected function performPaginatedSearch(Builder $builder): array + { + $page = 1; + $limit = min($builder->limit ?? $this->maxPerPage, $this->maxPerPage, $this->maxTotalResults); + $remainingResults = min($builder->limit ?? $this->maxTotalResults, $this->maxTotalResults); + + $results = new Collection(); + $totalFound = 0; + + while ($remainingResults > 0) { + /** @var array{hits?: array, found?: int} $searchResults */ + $searchResults = $this->performSearch( + $builder, + $this->buildSearchParameters($builder, $page, $limit) + ); + + $results = $results->concat($searchResults['hits'] ?? []); + + if ($page === 1) { + $totalFound = $searchResults['found'] ?? 0; + } + + $remainingResults -= $limit; + ++$page; + + if (count($searchResults['hits'] ?? []) < $limit) { + break; + } + } + + return [ + 'hits' => $results->all(), + 'found' => $results->count(), + 'out_of' => $totalFound, + 'page' => 1, + 'request_params' => $this->buildSearchParameters($builder, 1, $builder->limit ?? $this->maxPerPage), + ]; + } + + /** + * Build the search parameters for a given Scout query builder. + * + * @return array + */ + public function buildSearchParameters(Builder $builder, int $page, ?int $perPage): array + { + $modelClass = get_class($builder->model); + $modelSettings = $this->getConfig("typesense.model-settings.{$modelClass}.search-parameters", []); + + $parameters = [ + 'q' => $builder->query, + 'query_by' => $modelSettings['query_by'] ?? '', + 'filter_by' => $this->filters($builder), + 'per_page' => $perPage, + 'page' => $page, + 'highlight_start_tag' => '', + 'highlight_end_tag' => '', + 'snippet_threshold' => 30, + 'exhaustive_search' => false, + 'use_cache' => false, + 'cache_ttl' => 60, + 'prioritize_exact_match' => true, + 'enable_overrides' => true, + 'highlight_affix_num_tokens' => 4, + 'prefix' => $modelSettings['prefix'] ?? true, + ]; + + if (method_exists($builder->model, 'typesenseSearchParameters')) { + $parameters = array_merge($parameters, $builder->model->typesenseSearchParameters()); + } + + if (! empty($builder->options)) { + $parameters = array_merge($parameters, $builder->options); + } + + if (! empty($builder->orders)) { + if (! empty($parameters['sort_by'])) { + $parameters['sort_by'] .= ','; + } else { + $parameters['sort_by'] = ''; + } + + $parameters['sort_by'] .= $this->parseOrderBy($builder->orders); + } + + return $parameters; + } + + /** + * Prepare the filters for a given search query. + */ + protected function filters(Builder $builder): string + { + $whereFilter = collect($builder->wheres) + ->map(fn (mixed $value, string $key): string => $this->parseWhereFilter($this->parseFilterValue($value), $key)) + ->values() + ->implode(' && '); + + $whereInFilter = collect($builder->whereIns) + ->map(fn (array $value, string $key): string => $this->parseWhereInFilter($this->parseFilterValue($value), $key)) + ->values() + ->implode(' && '); + + $whereNotInFilter = collect($builder->whereNotIns) + ->map(fn (array $value, string $key): string => $this->parseWhereNotInFilter($this->parseFilterValue($value), $key)) + ->values() + ->implode(' && '); + + return collect([$whereFilter, $whereInFilter, $whereNotInFilter]) + ->filter() + ->implode(' && '); + } + + /** + * Parse the given filter value. + * + * @param array|bool|float|int|string $value + * @return array|float|int|string + */ + protected function parseFilterValue(array|string|bool|int|float $value): array|string|int|float + { + if (is_array($value)) { + return array_map([$this, 'parseFilterValue'], $value); + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + return $value; + } + + /** + * Create a "where" filter string. + * + * @param array|float|int|string $value + */ + protected function parseWhereFilter(array|string|int|float $value, string $key): string + { + return is_array($value) + ? sprintf('%s:%s', $key, implode('', $value)) + : sprintf('%s:=%s', $key, $value); + } + + /** + * Create a "where in" filter string. + * + * @param array $value + */ + protected function parseWhereInFilter(array $value, string $key): string + { + return sprintf('%s:=[%s]', $key, implode(', ', $value)); + } + + /** + * Create a "where not in" filter string. + * + * @param array $value + */ + protected function parseWhereNotInFilter(array $value, string $key): string + { + return sprintf('%s:!=[%s]', $key, implode(', ', $value)); + } + + /** + * Parse the order by fields for the query. + * + * @param array $orders + */ + protected function parseOrderBy(array $orders): string + { + $orderBy = []; + + foreach ($orders as $order) { + $orderBy[] = $order['column'] . ':' . $order['direction']; + } + + return implode(',', $orderBy); + } + + /** + * Pluck and return the primary keys of the given results. + */ + public function mapIds(mixed $results): Collection + { + return collect($results['hits'] ?? []) + ->pluck('document.id') + ->values(); + } + + /** + * Map the given results to instances of the given model. + * + * @param Model&SearchableInterface $model + * @return EloquentCollection + */ + public function map(Builder $builder, mixed $results, Model $model): EloquentCollection + { + if ($this->getTotalCount($results) === 0) { + return $model->newCollection(); + } + + $hits = isset($results['grouped_hits']) && ! empty($results['grouped_hits']) + ? $results['grouped_hits'] + : $results['hits']; + + $pluck = isset($results['grouped_hits']) && ! empty($results['grouped_hits']) + ? 'hits.0.document.id' + : 'document.id'; + + $objectIds = collect($hits) + ->pluck($pluck) + ->values() + ->all(); + + $objectIdPositions = array_flip($objectIds); + + /** @var EloquentCollection $scoutModels */ + $scoutModels = $model->getScoutModelsByIds($builder, $objectIds); + + return $scoutModels + ->filter(static function (Model $m) use ($objectIds): bool { + /** @var Model&SearchableInterface $m */ + return in_array($m->getScoutKey(), $objectIds, false); + }) + ->sortBy(static function (Model $m) use ($objectIdPositions): int { + /** @var Model&SearchableInterface $m */ + return $objectIdPositions[$m->getScoutKey()]; + }) + ->values(); + } + + /** + * Map the given results to instances of the given model via a lazy collection. + * + * @param Model&SearchableInterface $model + */ + public function lazyMap(Builder $builder, mixed $results, Model $model): LazyCollection + { + if ((int) ($results['found'] ?? 0) === 0) { + return LazyCollection::empty(); + } + + $objectIds = collect($results['hits'] ?? []) + ->pluck('document.id') + ->values() + ->all(); + + $objectIdPositions = array_flip($objectIds); + + return $model->queryScoutModelsByIds($builder, $objectIds) + ->cursor() + ->filter(static function (Model $m) use ($objectIds): bool { + /** @var Model&SearchableInterface $m */ + return in_array($m->getScoutKey(), $objectIds, false); + }) + ->sortBy(static function (Model $m) use ($objectIdPositions): int { + /** @var Model&SearchableInterface $m */ + return $objectIdPositions[$m->getScoutKey()]; + }) + ->values(); + } + + /** + * Get the total count from a raw result returned by the engine. + */ + public function getTotalCount(mixed $results): int + { + return (int) ($results['found'] ?? 0); + } + + /** + * Flush all of the model's records from the engine. + * + * @param Model&SearchableInterface $model + * @throws TypesenseClientError + */ + public function flush(Model $model): void + { + $this->getOrCreateCollectionFromModel($model)->delete(); + } + + /** + * Create a search index. + * + * @throws NotSupportedException + */ + public function createIndex(string $name, array $options = []): mixed + { + throw new NotSupportedException('Typesense indexes are created automatically upon adding objects.'); + } + + /** + * Delete a search index. + * + * @return array + * @throws TypesenseClientError + * @throws ObjectNotFound + */ + public function deleteIndex(string $name): array + { + return $this->typesense->getCollections()->{$name}->delete(); + } + + /** + * Get collection from model or create new one. + * + * @param Model&SearchableInterface $model + * @throws TypesenseClientError + */ + protected function getOrCreateCollectionFromModel( + Model $model, + ?string $collectionName = null, + bool $indexOperation = true + ): TypesenseCollection { + if (! $indexOperation) { + $collectionName = $collectionName ?? $model->searchableAs(); + } else { + $collectionName = $model->indexableAs(); + } + + $collection = $this->typesense->getCollections()->{$collectionName}; + + if (! $indexOperation) { + return $collection; + } + + // Determine if the collection exists in Typesense + try { + $collection->retrieve(); + $collection->setExists(true); + + return $collection; + } catch (TypesenseClientError) { + // Collection doesn't exist, will create it + } + + $modelClass = get_class($model); + $schema = $this->getConfig("typesense.model-settings.{$modelClass}.collection-schema", []); + + if (method_exists($model, 'typesenseCollectionSchema')) { + $schema = $model->typesenseCollectionSchema(); + } + + if (! isset($schema['name'])) { + $schema['name'] = $model->searchableAs(); + } + + try { + $this->typesense->getCollections()->create($schema); + } catch (ObjectAlreadyExists) { + // Collection already exists + } + + $collection->setExists(true); + + return $collection; + } + + /** + * Determine if model uses soft deletes. + */ + protected function usesSoftDelete(Model $model): bool + { + return in_array(SoftDeletes::class, class_uses_recursive($model), true); + } + + /** + * Get the underlying Typesense client. + */ + public function getTypesenseClient(): Typesense + { + return $this->typesense; + } + + /** + * Get a Scout configuration value. + */ + protected function getConfig(string $key, mixed $default = null): mixed + { + return ApplicationContext::getContainer() + ->get(ConfigInterface::class) + ->get("scout.{$key}", $default); + } + + /** + * Dynamically proxy missing methods to the Typesense client instance. + */ + public function __call(string $method, array $parameters): mixed + { + return $this->typesense->{$method}(...$parameters); + } +} diff --git a/src/scout/src/Events/ModelsFlushed.php b/src/scout/src/Events/ModelsFlushed.php new file mode 100644 index 000000000..8552389e3 --- /dev/null +++ b/src/scout/src/Events/ModelsFlushed.php @@ -0,0 +1,25 @@ + $models + */ + public function __construct( + public readonly Collection $models + ) { + } +} diff --git a/src/scout/src/Events/ModelsImported.php b/src/scout/src/Events/ModelsImported.php new file mode 100644 index 000000000..5bad87985 --- /dev/null +++ b/src/scout/src/Events/ModelsImported.php @@ -0,0 +1,25 @@ + $models + */ + public function __construct( + public readonly Collection $models + ) { + } +} diff --git a/src/scout/src/Exceptions/NotSupportedException.php b/src/scout/src/Exceptions/NotSupportedException.php new file mode 100644 index 000000000..79061073c --- /dev/null +++ b/src/scout/src/Exceptions/NotSupportedException.php @@ -0,0 +1,12 @@ + $models + */ + public function __construct( + public Collection $models + ) { + } + + /** + * Handle the job. + */ + public function handle(): void + { + if ($this->models->isEmpty()) { + return; + } + + /** @var Model&SearchableInterface $firstModel */ + $firstModel = $this->models->first(); + + $models = $firstModel->makeSearchableUsing($this->models); + + if ($models->isEmpty()) { + return; + } + + /** @var Model&SearchableInterface $searchableModel */ + $searchableModel = $models->first(); + + $searchableModel->searchableUsing()->update($models); + } +} diff --git a/src/scout/src/Jobs/RemoveFromSearch.php b/src/scout/src/Jobs/RemoveFromSearch.php new file mode 100644 index 000000000..30520701d --- /dev/null +++ b/src/scout/src/Jobs/RemoveFromSearch.php @@ -0,0 +1,46 @@ + $models + */ + public function __construct(Collection $models) + { + $this->models = RemoveableScoutCollection::make($models); + } + + /** + * Handle the job. + */ + public function handle(): void + { + if ($this->models->isNotEmpty()) { + /** @var Model&SearchableInterface $firstModel */ + $firstModel = $this->models->first(); + $firstModel->searchableUsing()->delete($this->models); + } + } +} diff --git a/src/scout/src/Jobs/RemoveableScoutCollection.php b/src/scout/src/Jobs/RemoveableScoutCollection.php new file mode 100644 index 000000000..98ae4afbc --- /dev/null +++ b/src/scout/src/Jobs/RemoveableScoutCollection.php @@ -0,0 +1,45 @@ + + */ +class RemoveableScoutCollection extends Collection +{ + /** + * Get the Scout identifiers for all of the entities. + * + * @return array + */ + public function getQueueableIds(): array + { + if ($this->isEmpty()) { + return []; + } + + /** @var Model $first */ + $first = $this->first(); + + if (in_array(Searchable::class, class_uses_recursive($first))) { + return $this->map(fn (SearchableInterface $model) => $model->getScoutKey())->all(); + } + + // Fallback to model primary keys (equivalent to Laravel's modelKeys()) + return $this->modelKeys(); + } +} diff --git a/src/scout/src/Scout.php b/src/scout/src/Scout.php new file mode 100644 index 000000000..140fcca40 --- /dev/null +++ b/src/scout/src/Scout.php @@ -0,0 +1,75 @@ + + */ + public static string $makeSearchableJob = MakeSearchable::class; + + /** + * The job class that removes models from the search index. + * + * @var class-string + */ + public static string $removeFromSearchJob = RemoveFromSearch::class; + + /** + * Get a Scout engine instance by name. + */ + public static function engine(?string $name = null): Engine + { + return app(EngineManager::class)->engine($name); + } + + /** + * Specify the job class that should make models searchable. + * + * @param class-string $class + */ + public static function makeSearchableUsing(string $class): void + { + static::$makeSearchableJob = $class; + } + + /** + * Specify the job class that should remove models from the search index. + * + * @param class-string $class + */ + public static function removeFromSearchUsing(string $class): void + { + static::$removeFromSearchJob = $class; + } + + /** + * Reset the job classes to their defaults. + * + * Useful for testing to ensure clean state between tests. + */ + public static function resetJobClasses(): void + { + static::$makeSearchableJob = MakeSearchable::class; + static::$removeFromSearchJob = RemoveFromSearch::class; + } +} diff --git a/src/scout/src/ScoutServiceProvider.php b/src/scout/src/ScoutServiceProvider.php new file mode 100644 index 000000000..c5f3fc2a6 --- /dev/null +++ b/src/scout/src/ScoutServiceProvider.php @@ -0,0 +1,83 @@ +mergeConfigFrom( + dirname(__DIR__) . '/config/scout.php', + 'scout' + ); + + $this->app->bind(EngineManager::class, EngineManager::class); + + $this->app->bind(MeilisearchClient::class, function () { + $config = $this->app->get(ConfigInterface::class); + + return new MeilisearchClient( + $config->get('scout.meilisearch.host', 'http://localhost:7700'), + $config->get('scout.meilisearch.key') + ); + }); + + $this->app->bind(TypesenseClient::class, function () { + $config = $this->app->get(ConfigInterface::class); + + return new TypesenseClient( + $config->get('scout.typesense.client-settings', []) + ); + }); + } + + /** + * Bootstrap Scout services. + */ + public function boot(): void + { + $this->registerPublishing(); + $this->registerCommands(); + } + + /** + * Register the package's publishable resources. + */ + protected function registerPublishing(): void + { + $this->publishes([ + dirname(__DIR__) . '/config/scout.php' => config_path('scout.php'), + ], 'scout-config'); + } + + /** + * Register the package's Artisan commands. + */ + protected function registerCommands(): void + { + $this->commands([ + DeleteAllIndexesCommand::class, + DeleteIndexCommand::class, + FlushCommand::class, + ImportCommand::class, + IndexCommand::class, + SyncIndexSettingsCommand::class, + ]); + } +} diff --git a/src/scout/src/Searchable.php b/src/scout/src/Searchable.php new file mode 100644 index 000000000..dbcc89073 --- /dev/null +++ b/src/scout/src/Searchable.php @@ -0,0 +1,578 @@ + + */ + protected array $scoutMetadata = []; + + /** + * Concurrent runner for command batch operations. + */ + protected static ?WaitConcurrent $scoutRunner = null; + + /** + * Boot the searchable trait. + */ + public static function bootSearchable(): void + { + static::addGlobalScope(new SearchableScope()); + + (new static())->registerSearchableMacros(); + + static::registerCallback('saved', function ($model): void { + if (! static::isSearchSyncingEnabled()) { + return; + } + + if (! $model->searchIndexShouldBeUpdated()) { + return; + } + + if (! $model->shouldBeSearchable()) { + if ($model->wasSearchableBeforeUpdate()) { + $model->unsearchable(); + } + return; + } + + $model->searchable(); + }); + + static::registerCallback('deleted', function ($model): void { + if (! static::isSearchSyncingEnabled()) { + return; + } + + if (! $model->wasSearchableBeforeDelete()) { + return; + } + + if (static::usesSoftDelete() && static::getScoutConfig('soft_delete', false)) { + $model->searchable(); + } else { + $model->unsearchable(); + } + }); + + static::registerCallback('forceDeleted', function ($model): void { + if (! static::isSearchSyncingEnabled()) { + return; + } + + $model->unsearchable(); + }); + + static::registerCallback('restored', function ($model): void { + if (! static::isSearchSyncingEnabled()) { + return; + } + + // Note: restored is a "forced update" - we don't check searchIndexShouldBeUpdated() + // because restored models should always be re-indexed + + if (! $model->shouldBeSearchable()) { + if ($model->wasSearchableBeforeUpdate()) { + $model->unsearchable(); + } + return; + } + + $model->searchable(); + }); + } + + /** + * Register the searchable macros on collections. + */ + public function registerSearchableMacros(): void + { + BaseCollection::macro('searchable', function () { + if ($this->isEmpty()) { + return; + } + $this->first()->queueMakeSearchable($this); + }); + + BaseCollection::macro('unsearchable', function () { + if ($this->isEmpty()) { + return; + } + $this->first()->queueRemoveFromSearch($this); + }); + + BaseCollection::macro('searchableSync', function () { + if ($this->isEmpty()) { + return; + } + $this->first()->syncMakeSearchable($this); + }); + + BaseCollection::macro('unsearchableSync', function () { + if ($this->isEmpty()) { + return; + } + $this->first()->syncRemoveFromSearch($this); + }); + } + + /** + * Dispatch the job to make the given models searchable. + */ + public function queueMakeSearchable(Collection $models): void + { + if ($models->isEmpty()) { + return; + } + + if (static::getScoutConfig('queue.enabled', false)) { + $jobClass = Scout::$makeSearchableJob; + $pendingDispatch = $jobClass::dispatch($models) + ->onConnection($models->first()->syncWithSearchUsing()) + ->onQueue($models->first()->syncWithSearchUsingQueue()); + + if (static::getScoutConfig('queue.after_commit', false)) { + $pendingDispatch->afterCommit(); + } + + return; + } + + static::dispatchSearchableJob(function () use ($models): void { + $this->syncMakeSearchable($models); + }); + } + + /** + * Synchronously make the given models searchable. + */ + public function syncMakeSearchable(Collection $models): void + { + if ($models->isEmpty()) { + return; + } + + $models = $models->first()->makeSearchableUsing($models); + + if ($models->isEmpty()) { + return; + } + + $models->first()->searchableUsing()->update($models); + } + + /** + * Dispatch the job to make the given models unsearchable. + */ + public function queueRemoveFromSearch(Collection $models): void + { + if ($models->isEmpty()) { + return; + } + + if (static::getScoutConfig('queue.enabled', false)) { + $jobClass = Scout::$removeFromSearchJob; + $pendingDispatch = $jobClass::dispatch($models) + ->onConnection($models->first()->syncWithSearchUsing()) + ->onQueue($models->first()->syncWithSearchUsingQueue()); + + if (static::getScoutConfig('queue.after_commit', false)) { + $pendingDispatch->afterCommit(); + } + + return; + } + + static::dispatchSearchableJob(function () use ($models): void { + $this->syncRemoveFromSearch($models); + }); + } + + /** + * Synchronously make the given models unsearchable. + */ + public function syncRemoveFromSearch(Collection $models): void + { + if ($models->isEmpty()) { + return; + } + + $models->first()->searchableUsing()->delete($models); + } + + /** + * Determine if the model should be searchable. + */ + public function shouldBeSearchable(): bool + { + return true; + } + + /** + * When updating a model, this method determines if we should update the search index. + */ + public function searchIndexShouldBeUpdated(): bool + { + return true; + } + + /** + * Perform a search against the model's indexed data. + * + * @return Builder + */ + public static function search(string $query = '', ?Closure $callback = null): Builder + { + return new Builder( + model: new static(), + query: $query, + callback: $callback, + softDelete: static::usesSoftDelete() && static::getScoutConfig('soft_delete', false) + ); + } + + /** + * Make all instances of the model searchable. + */ + public static function makeAllSearchable(?int $chunk = null): void + { + static::makeAllSearchableQuery()->searchable($chunk); + } + + /** + * Get a query builder for making all instances of the model searchable. + */ + public static function makeAllSearchableQuery(): EloquentBuilder + { + $self = new static(); + $softDelete = static::usesSoftDelete() && static::getScoutConfig('soft_delete', false); + + return $self->newQuery() + ->when(true, fn ($query) => $self->makeAllSearchableUsing($query)) + ->when($softDelete, fn ($query) => $query->withTrashed()) + ->orderBy($self->qualifyColumn($self->getScoutKeyName())); + } + + /** + * Modify the collection of models being made searchable. + * + * @param Collection $models + * @return Collection + */ + public function makeSearchableUsing(Collection $models): Collection + { + return $models; + } + + /** + * Modify the query used to retrieve models when making all of the models searchable. + */ + protected function makeAllSearchableUsing(EloquentBuilder $query): EloquentBuilder + { + return $query; + } + + /** + * Make the given model instance searchable. + */ + public function searchable(): void + { + $this->newCollection([$this])->searchable(); + } + + /** + * Synchronously make the given model instance searchable. + */ + public function searchableSync(): void + { + $this->newCollection([$this])->searchableSync(); + } + + /** + * Remove all instances of the model from the search index. + */ + public static function removeAllFromSearch(): void + { + $self = new static(); + $self->searchableUsing()->flush($self); + } + + /** + * Remove the given model instance from the search index. + */ + public function unsearchable(): void + { + $this->newCollection([$this])->unsearchable(); + } + + /** + * Synchronously remove the given model instance from the search index. + */ + public function unsearchableSync(): void + { + $this->newCollection([$this])->unsearchableSync(); + } + + /** + * Determine if the model existed in the search index prior to an update. + */ + public function wasSearchableBeforeUpdate(): bool + { + return true; + } + + /** + * Determine if the model existed in the search index prior to deletion. + */ + public function wasSearchableBeforeDelete(): bool + { + return true; + } + + /** + * Get the requested models from an array of object IDs. + */ + public function getScoutModelsByIds(Builder $builder, array $ids): Collection + { + return $this->queryScoutModelsByIds($builder, $ids)->get(); + } + + /** + * Get a query builder for retrieving the requested models from an array of object IDs. + */ + public function queryScoutModelsByIds(Builder $builder, array $ids): EloquentBuilder + { + $query = static::usesSoftDelete() + ? $this->withTrashed() + : $this->newQuery(); + + if ($builder->queryCallback) { + call_user_func($builder->queryCallback, $query); + } + + $whereIn = in_array($this->getScoutKeyType(), ['int', 'integer']) + ? 'whereIntegerInRaw' + : 'whereIn'; + + return $query->{$whereIn}( + $this->qualifyColumn($this->getScoutKeyName()), + $ids + ); + } + + /** + * Enable search syncing for this model. + */ + public static function enableSearchSyncing(): void + { + Context::set('__scout.syncing_disabled.' . static::class, false); + } + + /** + * Disable search syncing for this model. + */ + public static function disableSearchSyncing(): void + { + Context::set('__scout.syncing_disabled.' . static::class, true); + } + + /** + * Determine if search syncing is enabled for this model. + */ + public static function isSearchSyncingEnabled(): bool + { + return ! Context::get('__scout.syncing_disabled.' . static::class, false); + } + + /** + * Temporarily disable search syncing for the given callback. + */ + public static function withoutSyncingToSearch(callable $callback): mixed + { + static::disableSearchSyncing(); + + try { + return $callback(); + } finally { + static::enableSearchSyncing(); + } + } + + /** + * Get the index name for the model when searching. + */ + public function searchableAs(): string + { + return static::getScoutConfig('prefix', '') . $this->getTable(); + } + + /** + * Get the index name for the model when indexing. + */ + public function indexableAs(): string + { + return $this->searchableAs(); + } + + /** + * Get the indexable data array for the model. + */ + public function toSearchableArray(): array + { + return $this->toArray(); + } + + /** + * Get the Scout engine for the model. + */ + public function searchableUsing(): Engine + { + return ApplicationContext::getContainer()->get(EngineManager::class)->engine(); + } + + /** + * Get the queue connection that should be used when syncing. + */ + public function syncWithSearchUsing(): ?string + { + return static::getScoutConfig('queue.connection'); + } + + /** + * Get the queue that should be used with syncing. + */ + public function syncWithSearchUsingQueue(): ?string + { + return static::getScoutConfig('queue.queue'); + } + + /** + * Sync the soft deleted status for this model into the metadata. + * + * @return $this + */ + public function pushSoftDeleteMetadata(): static + { + return $this->withScoutMetadata('__soft_deleted', $this->trashed() ? 1 : 0); + } + + /** + * Get all Scout related metadata. + */ + public function scoutMetadata(): array + { + return $this->scoutMetadata; + } + + /** + * Set a Scout related metadata. + * + * @return $this + */ + public function withScoutMetadata(string $key, mixed $value): static + { + $this->scoutMetadata[$key] = $value; + + return $this; + } + + /** + * Get the value used to index the model. + */ + public function getScoutKey(): mixed + { + return $this->getKey(); + } + + /** + * Get the key name used to index the model. + */ + public function getScoutKeyName(): string + { + return $this->getKeyName(); + } + + /** + * Get the auto-incrementing key type for querying models. + */ + public function getScoutKeyType(): string + { + return $this->getKeyType(); + } + + /** + * Dispatch the job to scout the given models. + */ + protected static function dispatchSearchableJob(callable $job): void + { + // Command path: use WaitConcurrent for parallel execution + if (defined('SCOUT_COMMAND')) { + if (! static::$scoutRunner instanceof WaitConcurrent) { + static::$scoutRunner = new WaitConcurrent( + (int) static::getScoutConfig('command_concurrency', 50) + ); + } + + static::$scoutRunner->create($job); + return; + } + + // HTTP/queue path: schedule work at end of coroutine + Coroutine::defer($job); + } + + /** + * Wait for all pending searchable jobs to complete. + * + * Should be called at the end of Scout commands to ensure all + * concurrent indexing operations have finished. + */ + public static function waitForSearchableJobs(): void + { + if (static::$scoutRunner instanceof WaitConcurrent) { + static::$scoutRunner->wait(); + static::$scoutRunner = null; + } + } + + /** + * Determine if the current class should use soft deletes with searching. + */ + protected static function usesSoftDelete(): bool + { + return in_array(SoftDeletes::class, class_uses_recursive(static::class)); + } + + /** + * Get a Scout configuration value. + */ + protected static function getScoutConfig(string $key, mixed $default = null): mixed + { + return ApplicationContext::getContainer() + ->get(ConfigInterface::class) + ->get("scout.{$key}", $default); + } +} diff --git a/src/scout/src/SearchableScope.php b/src/scout/src/SearchableScope.php new file mode 100644 index 000000000..436e1dec1 --- /dev/null +++ b/src/scout/src/SearchableScope.php @@ -0,0 +1,88 @@ +macro('searchable', function (EloquentBuilder $builder, ?int $chunk = null) { + /** @var Model&SearchableInterface $model */ + $model = $builder->getModel(); + $scoutKeyName = $model->getScoutKeyName(); + $chunkSize = $chunk ?? static::getScoutConfig('chunk.searchable', 500); + + $builder->chunkById($chunkSize, function (EloquentCollection $models) { + /* @phpstan-ignore-next-line method.notFound, argument.type */ + $models->filter(fn ($m) => $m->shouldBeSearchable())->searchable(); + + /* @phpstan-ignore-next-line argument.type */ + static::dispatchEvent(new ModelsImported($models)); + }, $builder->qualifyColumn($scoutKeyName), $scoutKeyName); + }); + + $builder->macro('unsearchable', function (EloquentBuilder $builder, ?int $chunk = null) { + /** @var Model&SearchableInterface $model */ + $model = $builder->getModel(); + $scoutKeyName = $model->getScoutKeyName(); + $chunkSize = $chunk ?? static::getScoutConfig('chunk.unsearchable', 500); + + $builder->chunkById($chunkSize, function (EloquentCollection $models) { + /* @phpstan-ignore-next-line method.notFound */ + $models->unsearchable(); + + /* @phpstan-ignore-next-line argument.type */ + static::dispatchEvent(new ModelsFlushed($models)); + }, $builder->qualifyColumn($scoutKeyName), $scoutKeyName); + }); + } + + /** + * Get a Scout configuration value. + */ + protected static function getScoutConfig(string $key, mixed $default = null): mixed + { + return ApplicationContext::getContainer() + ->get(ConfigInterface::class) + ->get("scout.{$key}", $default); + } + + /** + * Dispatch an event through the event dispatcher. + */ + protected static function dispatchEvent(object $event): void + { + ApplicationContext::getContainer() + ->get(EventDispatcherInterface::class) + ->dispatch($event); + } +} diff --git a/tests/Scout/Feature/CollectionEngineTest.php b/tests/Scout/Feature/CollectionEngineTest.php new file mode 100644 index 000000000..a1034d964 --- /dev/null +++ b/tests/Scout/Feature/CollectionEngineTest.php @@ -0,0 +1,160 @@ + 'Hello World', 'body' => 'This is a test']); + SearchableModel::create(['title' => 'Foo Bar', 'body' => 'Another test']); + SearchableModel::create(['title' => 'Baz Qux', 'body' => 'No match here']); + + $results = SearchableModel::search('Hello')->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Hello World', $results->first()->title); + } + + public function testSearchReturnsAllModelsWithEmptyQuery() + { + SearchableModel::create(['title' => 'First', 'body' => 'Body 1']); + SearchableModel::create(['title' => 'Second', 'body' => 'Body 2']); + SearchableModel::create(['title' => 'Third', 'body' => 'Body 3']); + + $results = SearchableModel::search('')->get(); + + $this->assertCount(3, $results); + } + + public function testSearchWithWhereClause() + { + $model1 = SearchableModel::create(['title' => 'Test A', 'body' => 'Body']); + $model2 = SearchableModel::create(['title' => 'Test B', 'body' => 'Body']); + + $results = SearchableModel::search('') + ->where('id', $model1->id) + ->get(); + + $this->assertCount(1, $results); + $this->assertEquals($model1->id, $results->first()->id); + } + + public function testSearchWithWhereInClause() + { + $model1 = SearchableModel::create(['title' => 'Test A', 'body' => 'Body']); + $model2 = SearchableModel::create(['title' => 'Test B', 'body' => 'Body']); + $model3 = SearchableModel::create(['title' => 'Test C', 'body' => 'Body']); + + $results = SearchableModel::search('') + ->whereIn('id', [$model1->id, $model2->id]) + ->get(); + + $this->assertCount(2, $results); + } + + public function testSearchWithLimit() + { + SearchableModel::create(['title' => 'First', 'body' => 'Body']); + SearchableModel::create(['title' => 'Second', 'body' => 'Body']); + SearchableModel::create(['title' => 'Third', 'body' => 'Body']); + + $results = SearchableModel::search('')->take(2)->get(); + + $this->assertCount(2, $results); + } + + public function testSearchWithPagination() + { + for ($i = 1; $i <= 10; ++$i) { + SearchableModel::create(['title' => "Item {$i}", 'body' => 'Body']); + } + + $page1 = SearchableModel::search('')->paginate(3, 'page', 1); + $page2 = SearchableModel::search('')->paginate(3, 'page', 2); + + $this->assertCount(3, $page1->items()); + $this->assertCount(3, $page2->items()); + $this->assertEquals(10, $page1->total()); + } + + public function testSearchWithOrderBy() + { + SearchableModel::create(['title' => 'B Item', 'body' => 'Body']); + SearchableModel::create(['title' => 'A Item', 'body' => 'Body']); + SearchableModel::create(['title' => 'C Item', 'body' => 'Body']); + + $results = SearchableModel::search('') + ->orderBy('title', 'asc') + ->get(); + + $this->assertEquals('A Item', $results->first()->title); + $this->assertEquals('C Item', $results->last()->title); + } + + public function testSearchMatchesInBody() + { + SearchableModel::create(['title' => 'No match', 'body' => 'The quick brown fox']); + SearchableModel::create(['title' => 'Also no match', 'body' => 'Lazy dog']); + + $results = SearchableModel::search('fox')->get(); + + $this->assertCount(1, $results); + $this->assertEquals('No match', $results->first()->title); + } + + public function testSearchIsCaseInsensitive() + { + SearchableModel::create(['title' => 'UPPERCASE', 'body' => 'Body']); + SearchableModel::create(['title' => 'lowercase', 'body' => 'Body']); + + $results = SearchableModel::search('uppercase')->get(); + $this->assertCount(1, $results); + + $results = SearchableModel::search('LOWERCASE')->get(); + $this->assertCount(1, $results); + } + + public function testUpdateAndDeleteAreNoOps() + { + $model = SearchableModel::create(['title' => 'Test', 'body' => 'Body']); + $engine = new CollectionEngine(); + + // These should not throw exceptions + $engine->update($model->newCollection([$model])); + $engine->delete($model->newCollection([$model])); + $engine->flush($model); + + $this->assertTrue(true); + } + + public function testCreateAndDeleteIndexAreNoOps() + { + $engine = new CollectionEngine(); + + $this->assertNull($engine->createIndex('test')); + $this->assertNull($engine->deleteIndex('test')); + } + + public function testGetTotalCountReturnsCorrectCount() + { + SearchableModel::create(['title' => 'First', 'body' => 'Body']); + SearchableModel::create(['title' => 'Second', 'body' => 'Body']); + + $builder = SearchableModel::search(''); + $engine = new CollectionEngine(); + $results = $engine->search($builder); + + $this->assertEquals(2, $engine->getTotalCount($results)); + } +} diff --git a/tests/Scout/Feature/CoroutineSafetyTest.php b/tests/Scout/Feature/CoroutineSafetyTest.php new file mode 100644 index 000000000..22eac492d --- /dev/null +++ b/tests/Scout/Feature/CoroutineSafetyTest.php @@ -0,0 +1,187 @@ +assertTrue(SearchableModel::isSearchSyncingEnabled()); + + $results = []; + $waiter = new WaitGroup(); + + // Coroutine 1: Disable syncing + $waiter->add(1); + go(function () use (&$results, $waiter) { + SearchableModel::disableSearchSyncing(); + usleep(10000); // 10ms - let other coroutine start + + $results['coroutine1'] = SearchableModel::isSearchSyncingEnabled(); + $waiter->done(); + }); + + // Coroutine 2: Check syncing (should still be enabled in its context) + $waiter->add(1); + go(function () use (&$results, $waiter) { + usleep(5000); // 5ms - start after coroutine 1 disables syncing + + $results['coroutine2'] = SearchableModel::isSearchSyncingEnabled(); + $waiter->done(); + }); + + $waiter->wait(); + + // Coroutine 1 should have syncing disabled (it called disableSearchSyncing) + $this->assertFalse($results['coroutine1']); + + // Coroutine 2 should have syncing enabled (Context is isolated) + $this->assertTrue($results['coroutine2']); + } + + public function testWithoutSyncingToSearchIsCoroutineIsolated() + { + $results = []; + $waiter = new WaitGroup(); + + // Coroutine 1: Run without syncing + $waiter->add(1); + go(function () use (&$results, $waiter) { + SearchableModel::withoutSyncingToSearch(function () use (&$results) { + usleep(10000); // 10ms + $results['inside_callback'] = SearchableModel::isSearchSyncingEnabled(); + }); + + $results['after_callback'] = SearchableModel::isSearchSyncingEnabled(); + $waiter->done(); + }); + + // Coroutine 2: Check syncing during callback execution + $waiter->add(1); + go(function () use (&$results, $waiter) { + usleep(5000); // 5ms - check during callback + + $results['concurrent'] = SearchableModel::isSearchSyncingEnabled(); + $waiter->done(); + }); + + $waiter->wait(); + + // Inside callback, syncing should be disabled + $this->assertFalse($results['inside_callback']); + + // After callback, syncing should be restored + $this->assertTrue($results['after_callback']); + + // Concurrent coroutine should have syncing enabled + $this->assertTrue($results['concurrent']); + } + + public function testMultipleConcurrentDisableSync() + { + $results = []; + $waiter = new WaitGroup(); + + // Create multiple coroutines that each toggle syncing + for ($i = 0; $i < 5; ++$i) { + $waiter->add(1); + $coroutineId = $i; + + go(function () use (&$results, $waiter, $coroutineId) { + // Record initial state + $results["before_{$coroutineId}"] = SearchableModel::isSearchSyncingEnabled(); + + // Only disable for even coroutines + if ($coroutineId % 2 === 0) { + SearchableModel::disableSearchSyncing(); + } + + usleep(1000 * ($coroutineId + 1)); // Stagger execution + + $results["after_{$coroutineId}"] = SearchableModel::isSearchSyncingEnabled(); + $waiter->done(); + }); + } + + $waiter->wait(); + + // All coroutines should start with syncing enabled (fresh context) + for ($i = 0; $i < 5; ++$i) { + $this->assertTrue( + $results["before_{$i}"], + "Coroutine {$i} should start with syncing enabled" + ); + } + + // Even coroutines should have syncing disabled, odd should have enabled + for ($i = 0; $i < 5; ++$i) { + if ($i % 2 === 0) { + $this->assertFalse( + $results["after_{$i}"], + "Even coroutine {$i} should have syncing disabled" + ); + } else { + $this->assertTrue( + $results["after_{$i}"], + "Odd coroutine {$i} should have syncing enabled" + ); + } + } + } + + public function testNestedCoroutinesHaveIsolatedContext() + { + $results = []; + $waiter = new WaitGroup(); + + // Parent coroutine + $waiter->add(1); + go(function () use (&$results, $waiter) { + SearchableModel::disableSearchSyncing(); + $results['parent_before_child'] = SearchableModel::isSearchSyncingEnabled(); + + $childWaiter = new WaitGroup(); + + // Nested child coroutine + $childWaiter->add(1); + go(function () use (&$results, $childWaiter) { + // Child has its own fresh context + $results['child'] = SearchableModel::isSearchSyncingEnabled(); + $childWaiter->done(); + }); + + $childWaiter->wait(); + + $results['parent_after_child'] = SearchableModel::isSearchSyncingEnabled(); + $waiter->done(); + }); + + $waiter->wait(); + + // Parent should have syncing disabled (it called disableSearchSyncing) + $this->assertFalse($results['parent_before_child']); + $this->assertFalse($results['parent_after_child']); + + // Child should have syncing enabled (nested coroutine has fresh context) + $this->assertTrue($results['child']); + } +} diff --git a/tests/Scout/Feature/DatabaseEngineTest.php b/tests/Scout/Feature/DatabaseEngineTest.php new file mode 100644 index 000000000..2e25c828d --- /dev/null +++ b/tests/Scout/Feature/DatabaseEngineTest.php @@ -0,0 +1,323 @@ +app->get(ConfigInterface::class)->set('scout.driver', 'database'); + } + + public function testSearchReturnsMatchingModels(): void + { + SearchableModel::create(['title' => 'Hello World', 'body' => 'This is a test']); + SearchableModel::create(['title' => 'Foo Bar', 'body' => 'Another test']); + SearchableModel::create(['title' => 'Baz Qux', 'body' => 'No match here']); + + $results = SearchableModel::search('Hello')->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Hello World', $results->first()->title); + } + + public function testSearchReturnsAllModelsWithEmptyQuery(): void + { + SearchableModel::create(['title' => 'First', 'body' => 'Body 1']); + SearchableModel::create(['title' => 'Second', 'body' => 'Body 2']); + SearchableModel::create(['title' => 'Third', 'body' => 'Body 3']); + + $results = SearchableModel::search('')->get(); + + $this->assertCount(3, $results); + } + + public function testSearchWithWhereClause(): void + { + $model1 = SearchableModel::create(['title' => 'Test A', 'body' => 'Body']); + SearchableModel::create(['title' => 'Test B', 'body' => 'Body']); + + $results = SearchableModel::search('Test') + ->where('id', $model1->id) + ->get(); + + $this->assertCount(1, $results); + $this->assertEquals($model1->id, $results->first()->id); + } + + public function testSearchWithWhereInClause(): void + { + $model1 = SearchableModel::create(['title' => 'Test A', 'body' => 'Body']); + $model2 = SearchableModel::create(['title' => 'Test B', 'body' => 'Body']); + SearchableModel::create(['title' => 'Test C', 'body' => 'Body']); + + $results = SearchableModel::search('Test') + ->whereIn('id', [$model1->id, $model2->id]) + ->get(); + + $this->assertCount(2, $results); + } + + public function testSearchWithWhereNotInClause(): void + { + $model1 = SearchableModel::create(['title' => 'Test A', 'body' => 'Body']); + $model2 = SearchableModel::create(['title' => 'Test B', 'body' => 'Body']); + $model3 = SearchableModel::create(['title' => 'Test C', 'body' => 'Body']); + + $results = SearchableModel::search('Test') + ->whereNotIn('id', [$model1->id]) + ->get(); + + $this->assertCount(2, $results); + $this->assertFalse($results->contains('id', $model1->id)); + } + + public function testSearchWithLimit(): void + { + SearchableModel::create(['title' => 'First', 'body' => 'Body']); + SearchableModel::create(['title' => 'Second', 'body' => 'Body']); + SearchableModel::create(['title' => 'Third', 'body' => 'Body']); + + $results = SearchableModel::search('')->take(2)->get(); + + $this->assertCount(2, $results); + } + + public function testSearchWithPagination(): void + { + for ($i = 1; $i <= 10; ++$i) { + SearchableModel::create(['title' => "Item {$i}", 'body' => 'Body']); + } + + $page1 = SearchableModel::search('')->paginate(3, 'page', 1); + $page2 = SearchableModel::search('')->paginate(3, 'page', 2); + + $this->assertCount(3, $page1->items()); + $this->assertCount(3, $page2->items()); + $this->assertEquals(10, $page1->total()); + } + + public function testSearchWithOrderBy(): void + { + SearchableModel::create(['title' => 'B Item', 'body' => 'Body']); + SearchableModel::create(['title' => 'A Item', 'body' => 'Body']); + SearchableModel::create(['title' => 'C Item', 'body' => 'Body']); + + $results = SearchableModel::search('') + ->orderBy('title', 'asc') + ->get(); + + $this->assertEquals('A Item', $results->first()->title); + $this->assertEquals('C Item', $results->last()->title); + } + + public function testSearchMatchesInBody(): void + { + SearchableModel::create(['title' => 'No match', 'body' => 'The quick brown fox']); + SearchableModel::create(['title' => 'Also no match', 'body' => 'Lazy dog']); + + $results = SearchableModel::search('fox')->get(); + + $this->assertCount(1, $results); + $this->assertEquals('No match', $results->first()->title); + } + + public function testSearchIsCaseInsensitive(): void + { + SearchableModel::create(['title' => 'UPPERCASE', 'body' => 'Body']); + SearchableModel::create(['title' => 'lowercase', 'body' => 'Body']); + + // SQLite uses LIKE which is case-insensitive by default + $results = SearchableModel::search('uppercase')->get(); + $this->assertCount(1, $results); + + $results = SearchableModel::search('LOWERCASE')->get(); + $this->assertCount(1, $results); + } + + public function testSearchByPrimaryKey(): void + { + $model1 = SearchableModel::create(['title' => 'Test A', 'body' => 'Body']); + SearchableModel::create(['title' => 'Test B', 'body' => 'Body']); + + // Search by the ID as a string + $results = SearchableModel::search((string) $model1->id)->get(); + + $this->assertCount(1, $results); + $this->assertEquals($model1->id, $results->first()->id); + } + + public function testUpdateAndDeleteAreNoOps(): void + { + $model = SearchableModel::create(['title' => 'Test', 'body' => 'Body']); + $engine = new DatabaseEngine(); + + // These should not throw exceptions since database is the index + $engine->update($model->newCollection([$model])); + $engine->delete($model->newCollection([$model])); + $engine->flush($model); + + $this->assertTrue(true); + } + + public function testCreateAndDeleteIndexAreNoOps(): void + { + $engine = new DatabaseEngine(); + + $this->assertNull($engine->createIndex('test')); + $this->assertNull($engine->deleteIndex('test')); + } + + public function testGetTotalCountReturnsCorrectCount(): void + { + SearchableModel::create(['title' => 'First', 'body' => 'Body']); + SearchableModel::create(['title' => 'Second', 'body' => 'Body']); + + $builder = SearchableModel::search(''); + $engine = new DatabaseEngine(); + $results = $engine->search($builder); + + $this->assertEquals(2, $engine->getTotalCount($results)); + } + + public function testMapIdsReturnsCorrectIds(): void + { + $model1 = SearchableModel::create(['title' => 'First', 'body' => 'Body']); + $model2 = SearchableModel::create(['title' => 'Second', 'body' => 'Body']); + + $builder = SearchableModel::search(''); + $engine = new DatabaseEngine(); + $results = $engine->search($builder); + + $ids = $engine->mapIds($results); + + $this->assertContains($model1->id, $ids->all()); + $this->assertContains($model2->id, $ids->all()); + } + + public function testMapReturnsResults(): void + { + $model = SearchableModel::create(['title' => 'Test', 'body' => 'Body']); + + $builder = SearchableModel::search('Test'); + $engine = new DatabaseEngine(); + $results = $engine->search($builder); + + $mapped = $engine->map($builder, $results, $model); + + $this->assertCount(1, $mapped); + $this->assertEquals('Test', $mapped->first()->title); + } + + public function testLazyMapReturnsLazyCollection(): void + { + SearchableModel::create(['title' => 'Test', 'body' => 'Body']); + + $builder = SearchableModel::search('Test'); + $engine = new DatabaseEngine(); + $results = $engine->search($builder); + $model = new SearchableModel(); + + $lazyMapped = $engine->lazyMap($builder, $results, $model); + + $this->assertInstanceOf(\Hypervel\Support\LazyCollection::class, $lazyMapped); + $this->assertCount(1, $lazyMapped); + } + + public function testQueryCallbackIsApplied(): void + { + SearchableModel::create(['title' => 'First', 'body' => 'Body']); + SearchableModel::create(['title' => 'Second', 'body' => 'Body']); + SearchableModel::create(['title' => 'Third', 'body' => 'Body']); + + $results = SearchableModel::search('') + ->query(function ($query) { + return $query->where('title', 'First'); + }) + ->get(); + + $this->assertCount(1, $results); + $this->assertEquals('First', $results->first()->title); + } + + public function testSimplePagination(): void + { + for ($i = 1; $i <= 10; ++$i) { + SearchableModel::create(['title' => "Item {$i}", 'body' => 'Body']); + } + + $page = SearchableModel::search('')->simplePaginate(3, 'page', 1); + + $this->assertCount(3, $page->items()); + $this->assertTrue($page->hasMorePages()); + } + + public function testSearchUsingPrefixMatchesStartOfColumn(): void + { + // PrefixSearchableModel has #[SearchUsingPrefix(['title'])] + // This means title searches use 'query%' pattern instead of '%query%' + PrefixSearchableModel::create(['title' => 'Testing Prefix', 'body' => 'Body content']); + PrefixSearchableModel::create(['title' => 'Another Testing', 'body' => 'Body content']); + PrefixSearchableModel::create(['title' => 'Prefix Start', 'body' => 'Body content']); + + // "Test" should match "Testing Prefix" (starts with Test) + // but NOT "Another Testing" (Testing is in the middle) + $results = PrefixSearchableModel::search('Test')->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Testing Prefix', $results->first()->title); + } + + public function testSearchUsingPrefixDoesNotMatchMiddleOfColumn(): void + { + PrefixSearchableModel::create(['title' => 'Hello World', 'body' => 'Body']); + PrefixSearchableModel::create(['title' => 'World Hello', 'body' => 'Body']); + + // "World" should only match "World Hello" (starts with World) + // NOT "Hello World" (World is in the middle) + $results = PrefixSearchableModel::search('World')->get(); + + $this->assertCount(1, $results); + $this->assertEquals('World Hello', $results->first()->title); + } + + public function testSearchUsingPrefixStillMatchesBodyWithFullWildcard(): void + { + // Body column is NOT in SearchUsingPrefix, so it uses %query% + PrefixSearchableModel::create(['title' => 'No Match', 'body' => 'Contains keyword here']); + PrefixSearchableModel::create(['title' => 'Also No Match', 'body' => 'keyword at start']); + + // "keyword" should match both because body uses full wildcard + $results = PrefixSearchableModel::search('keyword')->get(); + + $this->assertCount(2, $results); + } + + public function testRegularModelUsesFullWildcardOnTitle(): void + { + // SearchableModel does NOT have SearchUsingPrefix + // So title should use %query% pattern + SearchableModel::create(['title' => 'Testing Prefix', 'body' => 'Body']); + SearchableModel::create(['title' => 'Another Testing', 'body' => 'Body']); + + // "Test" should match both because regular model uses %query% + $results = SearchableModel::search('Test')->get(); + + $this->assertCount(2, $results); + } +} diff --git a/tests/Scout/Feature/SearchableModelTest.php b/tests/Scout/Feature/SearchableModelTest.php new file mode 100644 index 000000000..8c927cb3d --- /dev/null +++ b/tests/Scout/Feature/SearchableModelTest.php @@ -0,0 +1,250 @@ +assertInstanceOf(\Hypervel\Scout\Builder::class, $builder); + $this->assertSame('test', $builder->query); + } + + public function testSearchableAsReturnsTableName() + { + $model = new SearchableModel(); + + $this->assertSame('searchable_models', $model->searchableAs()); + } + + public function testSearchableAsReturnsTableNameWithPrefix() + { + // Set a prefix in the config + $this->app->get(\Hyperf\Contract\ConfigInterface::class) + ->set('scout.prefix', 'test_'); + + $model = new SearchableModel(); + + $this->assertSame('test_searchable_models', $model->searchableAs()); + } + + public function testToSearchableArrayReturnsModelArray() + { + $model = SearchableModel::create([ + 'title' => 'Test Title', + 'body' => 'Test Body', + ]); + + $searchable = $model->toSearchableArray(); + + $this->assertArrayHasKey('id', $searchable); + $this->assertArrayHasKey('title', $searchable); + $this->assertArrayHasKey('body', $searchable); + $this->assertSame('Test Title', $searchable['title']); + } + + public function testGetScoutKeyReturnsModelKey() + { + $model = SearchableModel::create([ + 'title' => 'Test Title', + 'body' => 'Test Body', + ]); + + $this->assertSame($model->id, $model->getScoutKey()); + } + + public function testGetScoutKeyNameReturnsModelKeyName() + { + $model = new SearchableModel(); + + $this->assertSame('id', $model->getScoutKeyName()); + } + + public function testShouldBeSearchableReturnsTrueByDefault() + { + $model = new SearchableModel(); + + $this->assertTrue($model->shouldBeSearchable()); + } + + public function testDisableSearchSyncingPreventsIndexing() + { + // Initially syncing is enabled + $this->assertTrue(SearchableModel::isSearchSyncingEnabled()); + + // Disable syncing + SearchableModel::disableSearchSyncing(); + + $this->assertFalse(SearchableModel::isSearchSyncingEnabled()); + + // Re-enable syncing + SearchableModel::enableSearchSyncing(); + + $this->assertTrue(SearchableModel::isSearchSyncingEnabled()); + } + + public function testWithoutSyncingToSearchExecutesCallbackAndRestoresState() + { + $this->assertTrue(SearchableModel::isSearchSyncingEnabled()); + + $result = SearchableModel::withoutSyncingToSearch(function () { + // Syncing should be disabled inside callback + $this->assertFalse(SearchableModel::isSearchSyncingEnabled()); + return 'callback result'; + }); + + // Syncing should be restored after callback + $this->assertTrue(SearchableModel::isSearchSyncingEnabled()); + $this->assertSame('callback result', $result); + } + + public function testWithoutSyncingToSearchRestoresStateOnException() + { + $this->assertTrue(SearchableModel::isSearchSyncingEnabled()); + + try { + SearchableModel::withoutSyncingToSearch(function () { + throw new RuntimeException('Test exception'); + }); + } catch (RuntimeException) { + // Expected + } + + // Syncing should be restored even after exception + $this->assertTrue(SearchableModel::isSearchSyncingEnabled()); + } + + public function testMakeAllSearchableQueryReturnsBuilder() + { + $query = SearchableModel::makeAllSearchableQuery(); + + $this->assertInstanceOf(\Hypervel\Database\Eloquent\Builder::class, $query); + } + + public function testScoutMetadataCanBeSetAndRetrieved() + { + $model = new SearchableModel(); + + $model->withScoutMetadata('_rankingScore', 0.95); + $model->withScoutMetadata('_highlight', ['title' => 'test']); + + $metadata = $model->scoutMetadata(); + + $this->assertSame(0.95, $metadata['_rankingScore']); + $this->assertSame(['title' => 'test'], $metadata['_highlight']); + } + + public function testModelCanBeSearched() + { + // Create some models + SearchableModel::create(['title' => 'First Post', 'body' => 'Content']); + SearchableModel::create(['title' => 'Second Post', 'body' => 'More content']); + SearchableModel::create(['title' => 'Third Item', 'body' => 'Other content']); + + // Search should work with collection engine + $results = SearchableModel::search('Post')->get(); + + $this->assertCount(2, $results); + } + + public function testSoftDeletedModelsAreExcludedByDefault() + { + // Set soft delete config + $this->app->get(\Hyperf\Contract\ConfigInterface::class) + ->set('scout.soft_delete', true); + + $model = SoftDeletableSearchableModel::create([ + 'title' => 'Test Title', + 'body' => 'Test Body', + ]); + + // Delete the model + $model->delete(); + + // Search should not find the deleted model + $results = SoftDeletableSearchableModel::search('Test')->get(); + + $this->assertCount(0, $results); + } + + public function testSoftDeletedModelsCanBeIncludedWithWithTrashed() + { + // Set soft delete config + $this->app->get(\Hyperf\Contract\ConfigInterface::class) + ->set('scout.soft_delete', true); + + $model = SoftDeletableSearchableModel::create([ + 'title' => 'Test Title', + 'body' => 'Test Body', + ]); + + // Delete the model + $model->delete(); + + // Search with trashed should find the deleted model + $results = SoftDeletableSearchableModel::search('Test') + ->withTrashed() + ->get(); + + // Should find the model (note: CollectionEngine may not fully support this) + $this->assertCount(1, $results); + } + + public function testSearchIndexShouldBeUpdatedReturnsTrueByDefault() + { + $model = new SearchableModel(); + + $this->assertTrue($model->searchIndexShouldBeUpdated()); + } + + public function testWasSearchableBeforeUpdateReturnsTrueByDefault() + { + $model = new SearchableModel(); + + $this->assertTrue($model->wasSearchableBeforeUpdate()); + } + + public function testWasSearchableBeforeDeleteReturnsTrueByDefault() + { + $model = new SearchableModel(); + + $this->assertTrue($model->wasSearchableBeforeDelete()); + } + + public function testIndexableAsReturnsSearchableAsByDefault() + { + $model = new SearchableModel(); + + $this->assertSame($model->searchableAs(), $model->indexableAs()); + } + + public function testGetScoutKeyTypeReturnsModelKeyType() + { + $model = new SearchableModel(); + + $this->assertSame('int', $model->getScoutKeyType()); + } + + public function testMakeSearchableUsingReturnsModelsUnchangedByDefault() + { + $model = new SearchableModel(); + $collection = $model->newCollection([new SearchableModel(), new SearchableModel()]); + + $result = $model->makeSearchableUsing($collection); + + $this->assertSame($collection, $result); + } +} diff --git a/tests/Scout/Feature/SearchableScopeTest.php b/tests/Scout/Feature/SearchableScopeTest.php new file mode 100644 index 000000000..ef41969bd --- /dev/null +++ b/tests/Scout/Feature/SearchableScopeTest.php @@ -0,0 +1,125 @@ +app->get(ConfigInterface::class)->set('scout.driver', 'collection'); + } + + public function testSearchableMacroDispatchesModelsImportedEvent(): void + { + SearchableModel::create(['title' => 'First', 'body' => 'Body']); + SearchableModel::create(['title' => 'Second', 'body' => 'Body']); + + Event::fake([ModelsImported::class]); + + SearchableModel::query()->searchable(); + + Event::assertDispatched(ModelsImported::class, function (ModelsImported $event) { + return $event->models->count() === 2; + }); + } + + public function testUnsearchableMacroDispatchesModelsFlushedEvent(): void + { + SearchableModel::create(['title' => 'First', 'body' => 'Body']); + SearchableModel::create(['title' => 'Second', 'body' => 'Body']); + + Event::fake([ModelsFlushed::class]); + + SearchableModel::query()->unsearchable(); + + Event::assertDispatched(ModelsFlushed::class, function (ModelsFlushed $event) { + return $event->models->count() === 2; + }); + } + + public function testSearchableMacroFiltersModelsThroughShouldBeSearchable(): void + { + // ConditionalSearchableModel has shouldBeSearchable() that returns false + // when title contains "hidden" + ConditionalSearchableModel::create(['title' => 'Visible Item', 'body' => 'Body']); + ConditionalSearchableModel::create(['title' => 'hidden Item', 'body' => 'Body']); + ConditionalSearchableModel::create(['title' => 'Another Visible', 'body' => 'Body']); + + ConditionalSearchableModel::query()->searchable(); + + // Search should only find the 2 visible models (the hidden one was filtered out) + $searchResults = ConditionalSearchableModel::search('')->get(); + + $this->assertCount(2, $searchResults); + $this->assertTrue($searchResults->contains('title', 'Visible Item')); + $this->assertTrue($searchResults->contains('title', 'Another Visible')); + $this->assertFalse($searchResults->contains('title', 'hidden Item')); + } + + public function testSearchableMacroRespectsCustomChunkSize(): void + { + // Create 5 models + for ($i = 1; $i <= 5; ++$i) { + SearchableModel::create(['title' => "Item {$i}", 'body' => 'Body']); + } + + Event::fake([ModelsImported::class]); + + // Use chunk size of 2, should dispatch 3 events (2 + 2 + 1) + SearchableModel::query()->searchable(2); + + Event::assertDispatched(ModelsImported::class, 3); + } + + public function testUnsearchableMacroRespectsCustomChunkSize(): void + { + // Create 5 models + for ($i = 1; $i <= 5; ++$i) { + SearchableModel::create(['title' => "Item {$i}", 'body' => 'Body']); + } + + Event::fake([ModelsFlushed::class]); + + // Use chunk size of 2, should dispatch 3 events (2 + 2 + 1) + SearchableModel::query()->unsearchable(2); + + Event::assertDispatched(ModelsFlushed::class, 3); + } + + public function testSearchableMacroWorksWithQueryConstraints(): void + { + SearchableModel::create(['title' => 'Include This', 'body' => 'Body']); + SearchableModel::create(['title' => 'Exclude This', 'body' => 'Body']); + + Event::fake([ModelsImported::class]); + + // Only make models with "Include" in title searchable + SearchableModel::query() + ->where('title', 'like', '%Include%') + ->searchable(); + + Event::assertDispatched(ModelsImported::class, function (ModelsImported $event) { + return $event->models->count() === 1 + && $event->models->first()->title === 'Include This'; + }); + } +} diff --git a/tests/Scout/Integration/Meilisearch/MeilisearchCommandsIntegrationTest.php b/tests/Scout/Integration/Meilisearch/MeilisearchCommandsIntegrationTest.php new file mode 100644 index 000000000..f7bf6e6df --- /dev/null +++ b/tests/Scout/Integration/Meilisearch/MeilisearchCommandsIntegrationTest.php @@ -0,0 +1,100 @@ + 'First Document', 'body' => 'Content']); + SearchableModel::create(['title' => 'Second Document', 'body' => 'Content']); + SearchableModel::create(['title' => 'Third Document', 'body' => 'Content']); + }); + + // Verify models exist in DB + $this->assertCount(3, SearchableModel::all()); + + // Run the import command + $this->artisan('scout:import', ['model' => SearchableModel::class]) + ->expectsOutputToContain('have been imported') + ->assertOk(); + + $this->waitForMeilisearchTasks(); + + // Verify models are searchable + $results = SearchableModel::search('Document')->get(); + + $this->assertCount(3, $results); + } + + public function testFlushCommandRemovesModels(): void + { + // Create models without triggering Scout indexing + SearchableModel::withoutSyncingToSearch(function (): void { + SearchableModel::create(['title' => 'First', 'body' => 'Content']); + SearchableModel::create(['title' => 'Second', 'body' => 'Content']); + }); + + $this->artisan('scout:import', ['model' => SearchableModel::class]) + ->assertOk(); + + $this->waitForMeilisearchTasks(); + + // Verify models are indexed + $results = SearchableModel::search('')->get(); + $this->assertCount(2, $results); + + // Run the flush command + $this->artisan('scout:flush', ['model' => SearchableModel::class]) + ->assertOk(); + + $this->waitForMeilisearchTasks(); + + // Verify models are removed from the index + $results = SearchableModel::search('')->get(); + $this->assertCount(0, $results); + } + + public function testDeleteAllIndexesCommandRemovesAllIndexes(): void + { + // Create models without triggering Scout indexing + SearchableModel::withoutSyncingToSearch(function (): void { + SearchableModel::create(['title' => 'First', 'body' => 'Content']); + }); + + $this->artisan('scout:import', ['model' => SearchableModel::class]) + ->assertOk(); + + $this->waitForMeilisearchTasks(); + + // Verify model is indexed + $results = SearchableModel::search('')->get(); + $this->assertCount(1, $results); + + // Run the delete-all-indexes command + $this->artisan('scout:delete-all-indexes') + ->expectsOutputToContain('All indexes deleted successfully') + ->assertOk(); + + $this->waitForMeilisearchTasks(); + + // Searching should now fail or return empty because index is gone + // After deleting the index, Meilisearch will auto-create on next search + // so we just verify the command executed successfully + } +} diff --git a/tests/Scout/Integration/Meilisearch/MeilisearchConnectionTest.php b/tests/Scout/Integration/Meilisearch/MeilisearchConnectionTest.php new file mode 100644 index 000000000..326206511 --- /dev/null +++ b/tests/Scout/Integration/Meilisearch/MeilisearchConnectionTest.php @@ -0,0 +1,77 @@ +initializeMeilisearch(); + } + + public function testCanConnectToMeilisearch(): void + { + $health = $this->meilisearch->health(); + + $this->assertSame('available', $health['status']); + } + + public function testCanCreateAndDeleteIndex(): void + { + $indexName = $this->prefixedIndexName('test_index'); + + // Create index + $task = $this->meilisearch->createIndex($indexName, ['primaryKey' => 'id']); + $this->meilisearch->waitForTask($task['taskUid']); + + // Verify it exists + $index = $this->meilisearch->getIndex($indexName); + $this->assertSame($indexName, $index->getUid()); + + // Delete it + $this->meilisearch->deleteIndex($indexName); + } + + public function testCanIndexAndSearchDocuments(): void + { + $indexName = $this->prefixedIndexName('search_test'); + + // Create index + $task = $this->meilisearch->createIndex($indexName, ['primaryKey' => 'id']); + $this->meilisearch->waitForTask($task['taskUid']); + + $index = $this->meilisearch->index($indexName); + + // Add documents + $task = $index->addDocuments([ + ['id' => 1, 'title' => 'Hello World'], + ['id' => 2, 'title' => 'Goodbye World'], + ]); + $this->meilisearch->waitForTask($task['taskUid']); + + // Search + $results = $index->search('Hello'); + + $this->assertCount(1, $results->getHits()); + $this->assertSame('Hello World', $results->getHits()[0]['title']); + + // Cleanup + $this->meilisearch->deleteIndex($indexName); + } +} diff --git a/tests/Scout/Integration/Meilisearch/MeilisearchEngineIntegrationTest.php b/tests/Scout/Integration/Meilisearch/MeilisearchEngineIntegrationTest.php new file mode 100644 index 000000000..9bc864722 --- /dev/null +++ b/tests/Scout/Integration/Meilisearch/MeilisearchEngineIntegrationTest.php @@ -0,0 +1,213 @@ + 'Test Document', 'body' => 'Content here']); + + $this->engine->update(new EloquentCollection([$model])); + $this->waitForMeilisearchTasks(); + + $results = $this->meilisearch->index($model->searchableAs())->search('Test'); + + $this->assertCount(1, $results->getHits()); + $this->assertSame('Test Document', $results->getHits()[0]['title']); + } + + public function testUpdateWithMultipleModels(): void + { + $models = new EloquentCollection([ + SearchableModel::create(['title' => 'First', 'body' => 'Body 1']), + SearchableModel::create(['title' => 'Second', 'body' => 'Body 2']), + SearchableModel::create(['title' => 'Third', 'body' => 'Body 3']), + ]); + + $this->engine->update($models); + $this->waitForMeilisearchTasks(); + + $results = $this->meilisearch->index($models->first()->searchableAs())->search(''); + + $this->assertCount(3, $results->getHits()); + } + + public function testDeleteRemovesModelsFromMeilisearch(): void + { + $model = SearchableModel::create(['title' => 'To Delete', 'body' => 'Content']); + + $this->engine->update(new EloquentCollection([$model])); + $this->waitForMeilisearchTasks(); + + // Verify it exists + $results = $this->meilisearch->index($model->searchableAs())->search('Delete'); + $this->assertCount(1, $results->getHits()); + + // Delete it + $this->engine->delete(new EloquentCollection([$model])); + $this->waitForMeilisearchTasks(); + + // Verify it's gone + $results = $this->meilisearch->index($model->searchableAs())->search('Delete'); + $this->assertCount(0, $results->getHits()); + } + + public function testSearchReturnsMatchingResults(): void + { + SearchableModel::create(['title' => 'PHP Programming', 'body' => 'Learn PHP']); + SearchableModel::create(['title' => 'JavaScript Guide', 'body' => 'Learn JS']); + SearchableModel::create(['title' => 'PHP Best Practices', 'body' => 'Advanced PHP']); + + SearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + $this->waitForMeilisearchTasks(); + + $results = SearchableModel::search('PHP')->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains('title', 'PHP Programming')); + $this->assertTrue($results->contains('title', 'PHP Best Practices')); + } + + public function testSearchWithEmptyQueryReturnsAllDocuments(): void + { + SearchableModel::create(['title' => 'First', 'body' => 'Body']); + SearchableModel::create(['title' => 'Second', 'body' => 'Body']); + + SearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + $this->waitForMeilisearchTasks(); + + $results = SearchableModel::search('')->get(); + + $this->assertCount(2, $results); + } + + public function testPaginateReturnsCorrectPage(): void + { + for ($i = 1; $i <= 10; ++$i) { + SearchableModel::create(['title' => "Item {$i}", 'body' => 'Body']); + } + + SearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + $this->waitForMeilisearchTasks(); + + $page1 = SearchableModel::search('')->paginate(3, 'page', 1); + $page2 = SearchableModel::search('')->paginate(3, 'page', 2); + + $this->assertCount(3, $page1); + $this->assertCount(3, $page2); + $this->assertSame(10, $page1->total()); + } + + public function testFlushRemovesAllDocumentsFromIndex(): void + { + $models = new EloquentCollection([ + SearchableModel::create(['title' => 'First', 'body' => 'Body']), + SearchableModel::create(['title' => 'Second', 'body' => 'Body']), + ]); + + $this->engine->update($models); + $this->waitForMeilisearchTasks(); + + // Verify documents exist + $results = $this->meilisearch->index($models->first()->searchableAs())->search(''); + $this->assertCount(2, $results->getHits()); + + // Flush + $this->engine->flush($models->first()); + $this->waitForMeilisearchTasks(); + + // Verify empty + $results = $this->meilisearch->index($models->first()->searchableAs())->search(''); + $this->assertCount(0, $results->getHits()); + } + + public function testCreateIndexCreatesNewIndex(): void + { + $indexName = $this->prefixedIndexName('new_index'); + + $this->engine->createIndex($indexName, ['primaryKey' => 'id']); + $this->waitForMeilisearchTasks(); + + $index = $this->meilisearch->getIndex($indexName); + + $this->assertSame($indexName, $index->getUid()); + } + + public function testDeleteIndexRemovesIndex(): void + { + $indexName = $this->prefixedIndexName('to_delete'); + + $this->engine->createIndex($indexName); + $this->waitForMeilisearchTasks(); + + $this->engine->deleteIndex($indexName); + $this->waitForMeilisearchTasks(); + + $this->expectException(\Meilisearch\Exceptions\ApiException::class); + $this->meilisearch->getIndex($indexName); + } + + public function testGetTotalCountReturnsCorrectCount(): void + { + for ($i = 1; $i <= 5; ++$i) { + SearchableModel::create(['title' => "Item {$i}", 'body' => 'Body']); + } + + SearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + $this->waitForMeilisearchTasks(); + + $builder = SearchableModel::search(''); + $results = $this->engine->search($builder); + + $this->assertSame(5, $this->engine->getTotalCount($results)); + } + + public function testMapIdsReturnsCollectionOfIds(): void + { + $models = new EloquentCollection([ + SearchableModel::create(['title' => 'First', 'body' => 'Body']), + SearchableModel::create(['title' => 'Second', 'body' => 'Body']), + ]); + + $this->engine->update($models); + $this->waitForMeilisearchTasks(); + + $builder = SearchableModel::search(''); + $results = $this->engine->search($builder); + $ids = $this->engine->mapIds($results); + + $this->assertCount(2, $ids); + $this->assertTrue($ids->contains($models[0]->id)); + $this->assertTrue($ids->contains($models[1]->id)); + } + + public function testKeysReturnsScoutKeys(): void + { + $models = new EloquentCollection([ + SearchableModel::create(['title' => 'First', 'body' => 'Body']), + SearchableModel::create(['title' => 'Second', 'body' => 'Body']), + ]); + + $this->engine->update($models); + $this->waitForMeilisearchTasks(); + + $keys = SearchableModel::search('')->keys(); + + $this->assertCount(2, $keys); + } +} diff --git a/tests/Scout/Integration/Meilisearch/MeilisearchFilteringIntegrationTest.php b/tests/Scout/Integration/Meilisearch/MeilisearchFilteringIntegrationTest.php new file mode 100644 index 000000000..e38e2e982 --- /dev/null +++ b/tests/Scout/Integration/Meilisearch/MeilisearchFilteringIntegrationTest.php @@ -0,0 +1,152 @@ +configureFilterableIndex(); + } + + protected function configureFilterableIndex(): void + { + $indexName = $this->prefixedIndexName('searchable_models'); + + // Create index and configure filterable attributes + $task = $this->meilisearch->createIndex($indexName, ['primaryKey' => 'id']); + $this->meilisearch->waitForTask($task['taskUid']); + + $index = $this->meilisearch->index($indexName); + $task = $index->updateSettings([ + 'filterableAttributes' => ['id', 'title', 'body'], + 'sortableAttributes' => ['id', 'title'], + ]); + $this->meilisearch->waitForTask($task['taskUid']); + } + + public function testWhereFiltersResultsByExactMatch(): void + { + SearchableModel::create(['title' => 'PHP Guide', 'body' => 'Learn PHP']); + SearchableModel::create(['title' => 'JavaScript Guide', 'body' => 'Learn JS']); + SearchableModel::create(['title' => 'PHP Advanced', 'body' => 'Advanced PHP']); + + SearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + $this->waitForMeilisearchTasks(); + + $results = SearchableModel::search('') + ->where('title', 'PHP Guide') + ->get(); + + $this->assertCount(1, $results); + $this->assertSame('PHP Guide', $results->first()->title); + } + + public function testWhereWithNumericValue(): void + { + $model1 = SearchableModel::create(['title' => 'First', 'body' => 'Body']); + $model2 = SearchableModel::create(['title' => 'Second', 'body' => 'Body']); + + SearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + $this->waitForMeilisearchTasks(); + + $results = SearchableModel::search('') + ->where('id', $model1->id) + ->get(); + + $this->assertCount(1, $results); + $this->assertSame($model1->id, $results->first()->id); + } + + public function testWhereInFiltersResultsByMultipleValues(): void + { + $model1 = SearchableModel::create(['title' => 'First', 'body' => 'Body']); + $model2 = SearchableModel::create(['title' => 'Second', 'body' => 'Body']); + $model3 = SearchableModel::create(['title' => 'Third', 'body' => 'Body']); + + SearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + $this->waitForMeilisearchTasks(); + + $results = SearchableModel::search('') + ->whereIn('id', [$model1->id, $model3->id]) + ->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains('id', $model1->id)); + $this->assertTrue($results->contains('id', $model3->id)); + $this->assertFalse($results->contains('id', $model2->id)); + } + + public function testWhereNotInExcludesSpecifiedValues(): void + { + $model1 = SearchableModel::create(['title' => 'First', 'body' => 'Body']); + $model2 = SearchableModel::create(['title' => 'Second', 'body' => 'Body']); + $model3 = SearchableModel::create(['title' => 'Third', 'body' => 'Body']); + + SearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + $this->waitForMeilisearchTasks(); + + $results = SearchableModel::search('') + ->whereNotIn('id', [$model1->id, $model3->id]) + ->get(); + + $this->assertCount(1, $results); + $this->assertSame($model2->id, $results->first()->id); + } + + public function testMultipleWhereClausesAreCombinedWithAnd(): void + { + SearchableModel::create(['title' => 'PHP Guide', 'body' => 'Content A']); + SearchableModel::create(['title' => 'PHP Guide', 'body' => 'Content B']); + SearchableModel::create(['title' => 'JS Guide', 'body' => 'Content A']); + + SearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + $this->waitForMeilisearchTasks(); + + $results = SearchableModel::search('') + ->where('title', 'PHP Guide') + ->where('body', 'Content A') + ->get(); + + $this->assertCount(1, $results); + $this->assertSame('PHP Guide', $results->first()->title); + $this->assertSame('Content A', $results->first()->body); + } + + public function testCombinedWhereAndWhereIn(): void + { + $model1 = SearchableModel::create(['title' => 'PHP', 'body' => 'A']); + $model2 = SearchableModel::create(['title' => 'PHP', 'body' => 'B']); + $model3 = SearchableModel::create(['title' => 'JS', 'body' => 'A']); + $model4 = SearchableModel::create(['title' => 'PHP', 'body' => 'C']); + + SearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + $this->waitForMeilisearchTasks(); + + $results = SearchableModel::search('') + ->where('title', 'PHP') + ->whereIn('body', ['A', 'B']) + ->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains('id', $model1->id)); + $this->assertTrue($results->contains('id', $model2->id)); + } +} diff --git a/tests/Scout/Integration/Meilisearch/MeilisearchIndexSettingsIntegrationTest.php b/tests/Scout/Integration/Meilisearch/MeilisearchIndexSettingsIntegrationTest.php new file mode 100644 index 000000000..90d31ee93 --- /dev/null +++ b/tests/Scout/Integration/Meilisearch/MeilisearchIndexSettingsIntegrationTest.php @@ -0,0 +1,96 @@ +prefixedIndexName('searchable_models'); + + // Create the index first + $task = $this->meilisearch->createIndex($indexName, ['primaryKey' => 'id']); + $this->meilisearch->waitForTask($task['taskUid']); + + // Configure index settings via Scout config + $this->app->get(ConfigInterface::class)->set('scout.meilisearch.index-settings', [ + SearchableModel::class => [ + 'filterableAttributes' => ['title', 'body'], + 'sortableAttributes' => ['id', 'title'], + 'searchableAttributes' => ['title', 'body'], + ], + ]); + + // Run the sync command + $this->artisan('scout:sync-index-settings') + ->assertOk() + ->expectsOutputToContain('synced successfully'); + + $this->waitForMeilisearchTasks(); + + // Verify the settings were applied + $index = $this->meilisearch->index($indexName); + $settings = $index->getSettings(); + + $this->assertContains('title', $settings['filterableAttributes']); + $this->assertContains('body', $settings['filterableAttributes']); + $this->assertContains('id', $settings['sortableAttributes']); + $this->assertContains('title', $settings['sortableAttributes']); + $this->assertEquals(['title', 'body'], $settings['searchableAttributes']); + } + + public function testSyncIndexSettingsCommandWithPlainIndexName(): void + { + $indexName = $this->prefixedIndexName('custom_index'); + + // Create the index first + $task = $this->meilisearch->createIndex($indexName, ['primaryKey' => 'id']); + $this->meilisearch->waitForTask($task['taskUid']); + + // Configure index settings using plain index name (with prefix) + $this->app->get(ConfigInterface::class)->set('scout.meilisearch.index-settings', [ + $indexName => [ + 'filterableAttributes' => ['status'], + 'sortableAttributes' => ['created_at'], + ], + ]); + + // Run the sync command + $this->artisan('scout:sync-index-settings') + ->assertOk(); + + $this->waitForMeilisearchTasks(); + + // Verify the settings were applied + $index = $this->meilisearch->index($indexName); + $settings = $index->getSettings(); + + $this->assertContains('status', $settings['filterableAttributes']); + $this->assertContains('created_at', $settings['sortableAttributes']); + } + + public function testSyncIndexSettingsCommandReportsNoSettingsWhenEmpty(): void + { + // Ensure no index settings are configured + $this->app->get(ConfigInterface::class)->set('scout.meilisearch.index-settings', []); + + // Run the sync command + $this->artisan('scout:sync-index-settings') + ->assertOk() + ->expectsOutputToContain('No index settings found'); + } +} diff --git a/tests/Scout/Integration/Meilisearch/MeilisearchScoutIntegrationTestCase.php b/tests/Scout/Integration/Meilisearch/MeilisearchScoutIntegrationTestCase.php new file mode 100644 index 000000000..6dd610810 --- /dev/null +++ b/tests/Scout/Integration/Meilisearch/MeilisearchScoutIntegrationTestCase.php @@ -0,0 +1,98 @@ +registerScoutCommands(); + + // Clear cached engines so they're recreated with our test config + $this->app->get(EngineManager::class)->forgetEngines(); + } + + protected function setUpInCoroutine(): void + { + $this->initializeMeilisearch(); + $this->engine = $this->app->get(EngineManager::class)->engine('meilisearch'); + } + + protected function tearDownInCoroutine(): void + { + $this->cleanupTestIndexes(); + } + + /** + * Register Scout commands with the Artisan application. + */ + protected function registerScoutCommands(): void + { + Artisan::getArtisan()->resolveCommands([ + DeleteAllIndexesCommand::class, + DeleteIndexCommand::class, + FlushCommand::class, + ImportCommand::class, + IndexCommand::class, + SyncIndexSettingsCommand::class, + ]); + } + + protected function migrateFreshUsing(): array + { + return [ + '--seed' => $this->shouldSeed(), + '--database' => $this->getRefreshConnection(), + '--realpath' => true, + '--path' => [ + dirname(__DIR__, 2) . '/migrations', + ], + ]; + } + + /** + * Wait for all pending Meilisearch tasks to complete. + */ + protected function waitForMeilisearchTasks(int $timeoutMs = 10000): void + { + $this->waitForTasks($timeoutMs); + } +} diff --git a/tests/Scout/Integration/Meilisearch/MeilisearchSoftDeleteIntegrationTest.php b/tests/Scout/Integration/Meilisearch/MeilisearchSoftDeleteIntegrationTest.php new file mode 100644 index 000000000..b1d62b233 --- /dev/null +++ b/tests/Scout/Integration/Meilisearch/MeilisearchSoftDeleteIntegrationTest.php @@ -0,0 +1,128 @@ +app->get(ConfigInterface::class)->set('scout.soft_delete', true); + } + + protected function setUpInCoroutine(): void + { + parent::setUpInCoroutine(); + + $this->configureSoftDeleteIndex(); + } + + protected function configureSoftDeleteIndex(): void + { + $indexName = $this->prefixedIndexName('soft_deletable_searchable_models'); + + $task = $this->meilisearch->createIndex($indexName, ['primaryKey' => 'id']); + $this->meilisearch->waitForTask($task['taskUid']); + + $index = $this->meilisearch->index($indexName); + $task = $index->updateSettings([ + 'filterableAttributes' => ['__soft_deleted'], + ]); + $this->meilisearch->waitForTask($task['taskUid']); + } + + public function testDefaultSearchExcludesSoftDeletedModels(): void + { + $model1 = SoftDeleteSearchableModel::create(['title' => 'Active One', 'body' => 'Content']); + $model2 = SoftDeleteSearchableModel::create(['title' => 'Active Two', 'body' => 'Content']); + $model3 = SoftDeleteSearchableModel::create(['title' => 'Deleted One', 'body' => 'Content']); + + // Index all models + SoftDeleteSearchableModel::withTrashed()->get()->each( + fn ($m) => $this->engine->update(new EloquentCollection([$m])) + ); + $this->waitForMeilisearchTasks(); + + // Soft delete one model and re-index it + $model3->delete(); + $this->engine->update(new EloquentCollection([$model3->fresh()])); + $this->waitForMeilisearchTasks(); + + // Default search should exclude soft-deleted model + $results = SoftDeleteSearchableModel::search('')->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains('id', $model1->id)); + $this->assertTrue($results->contains('id', $model2->id)); + $this->assertFalse($results->contains('id', $model3->id)); + } + + public function testWithTrashedIncludesSoftDeletedModels(): void + { + $model1 = SoftDeleteSearchableModel::create(['title' => 'Active', 'body' => 'Content']); + $model2 = SoftDeleteSearchableModel::create(['title' => 'Deleted', 'body' => 'Content']); + + // Index all models + SoftDeleteSearchableModel::withTrashed()->get()->each( + fn ($m) => $this->engine->update(new EloquentCollection([$m])) + ); + $this->waitForMeilisearchTasks(); + + // Soft delete one model and re-index it + $model2->delete(); + $this->engine->update(new EloquentCollection([$model2->fresh()])); + $this->waitForMeilisearchTasks(); + + // withTrashed should include soft-deleted model + $results = SoftDeleteSearchableModel::search('')->withTrashed()->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains('id', $model1->id)); + $this->assertTrue($results->contains('id', $model2->id)); + } + + public function testOnlyTrashedReturnsOnlySoftDeletedModels(): void + { + $model1 = SoftDeleteSearchableModel::create(['title' => 'Active', 'body' => 'Content']); + $model2 = SoftDeleteSearchableModel::create(['title' => 'Deleted One', 'body' => 'Content']); + $model3 = SoftDeleteSearchableModel::create(['title' => 'Deleted Two', 'body' => 'Content']); + + // Index all models + SoftDeleteSearchableModel::withTrashed()->get()->each( + fn ($m) => $this->engine->update(new EloquentCollection([$m])) + ); + $this->waitForMeilisearchTasks(); + + // Soft delete two models and re-index them + $model2->delete(); + $model3->delete(); + $this->engine->update(new EloquentCollection([$model2->fresh()])); + $this->engine->update(new EloquentCollection([$model3->fresh()])); + $this->waitForMeilisearchTasks(); + + // onlyTrashed should return only soft-deleted models + $results = SoftDeleteSearchableModel::search('')->onlyTrashed()->get(); + + $this->assertCount(2, $results); + $this->assertFalse($results->contains('id', $model1->id)); + $this->assertTrue($results->contains('id', $model2->id)); + $this->assertTrue($results->contains('id', $model3->id)); + } +} diff --git a/tests/Scout/Integration/Meilisearch/MeilisearchSortingIntegrationTest.php b/tests/Scout/Integration/Meilisearch/MeilisearchSortingIntegrationTest.php new file mode 100644 index 000000000..fc51c1ff0 --- /dev/null +++ b/tests/Scout/Integration/Meilisearch/MeilisearchSortingIntegrationTest.php @@ -0,0 +1,115 @@ +configureSortableIndex(); + } + + protected function configureSortableIndex(): void + { + $indexName = $this->prefixedIndexName('searchable_models'); + + $task = $this->meilisearch->createIndex($indexName, ['primaryKey' => 'id']); + $this->meilisearch->waitForTask($task['taskUid']); + + $index = $this->meilisearch->index($indexName); + $task = $index->updateSettings([ + 'sortableAttributes' => ['id', 'title'], + ]); + $this->meilisearch->waitForTask($task['taskUid']); + } + + public function testOrderByAscendingSortsResultsCorrectly(): void + { + SearchableModel::create(['title' => 'Charlie', 'body' => 'Body']); + SearchableModel::create(['title' => 'Alpha', 'body' => 'Body']); + SearchableModel::create(['title' => 'Bravo', 'body' => 'Body']); + + SearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + $this->waitForMeilisearchTasks(); + + $results = SearchableModel::search('') + ->orderBy('title', 'asc') + ->get(); + + $this->assertCount(3, $results); + $this->assertSame('Alpha', $results[0]->title); + $this->assertSame('Bravo', $results[1]->title); + $this->assertSame('Charlie', $results[2]->title); + } + + public function testOrderByDescendingSortsResultsCorrectly(): void + { + SearchableModel::create(['title' => 'Alpha', 'body' => 'Body']); + SearchableModel::create(['title' => 'Bravo', 'body' => 'Body']); + SearchableModel::create(['title' => 'Charlie', 'body' => 'Body']); + + SearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + $this->waitForMeilisearchTasks(); + + $results = SearchableModel::search('') + ->orderBy('title', 'desc') + ->get(); + + $this->assertCount(3, $results); + $this->assertSame('Charlie', $results[0]->title); + $this->assertSame('Bravo', $results[1]->title); + $this->assertSame('Alpha', $results[2]->title); + } + + public function testOrderByDescHelperMethod(): void + { + SearchableModel::create(['title' => 'Alpha', 'body' => 'Body']); + SearchableModel::create(['title' => 'Bravo', 'body' => 'Body']); + + SearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + $this->waitForMeilisearchTasks(); + + $results = SearchableModel::search('') + ->orderByDesc('title') + ->get(); + + $this->assertCount(2, $results); + $this->assertSame('Bravo', $results[0]->title); + $this->assertSame('Alpha', $results[1]->title); + } + + public function testOrderByNumericField(): void + { + $model1 = SearchableModel::create(['title' => 'First', 'body' => 'Body']); + $model2 = SearchableModel::create(['title' => 'Second', 'body' => 'Body']); + $model3 = SearchableModel::create(['title' => 'Third', 'body' => 'Body']); + + SearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + $this->waitForMeilisearchTasks(); + + $results = SearchableModel::search('') + ->orderBy('id', 'desc') + ->get(); + + $this->assertCount(3, $results); + $this->assertSame($model3->id, $results[0]->id); + $this->assertSame($model2->id, $results[1]->id); + $this->assertSame($model1->id, $results[2]->id); + } +} diff --git a/tests/Scout/Integration/Typesense/TypesenseCommandsIntegrationTest.php b/tests/Scout/Integration/Typesense/TypesenseCommandsIntegrationTest.php new file mode 100644 index 000000000..7531ab361 --- /dev/null +++ b/tests/Scout/Integration/Typesense/TypesenseCommandsIntegrationTest.php @@ -0,0 +1,62 @@ + 'First Document', 'body' => 'Content']); + TypesenseSearchableModel::create(['title' => 'Second Document', 'body' => 'Content']); + TypesenseSearchableModel::create(['title' => 'Third Document', 'body' => 'Content']); + }); + + // Run the import command + $this->artisan('scout:import', ['model' => TypesenseSearchableModel::class]) + ->assertOk(); + + // Verify models are searchable + $results = TypesenseSearchableModel::search('Document')->get(); + + $this->assertCount(3, $results); + } + + public function testFlushCommandRemovesModels(): void + { + // Create models without triggering Scout indexing + TypesenseSearchableModel::withoutSyncingToSearch(function (): void { + TypesenseSearchableModel::create(['title' => 'First', 'body' => 'Content']); + TypesenseSearchableModel::create(['title' => 'Second', 'body' => 'Content']); + }); + + $this->artisan('scout:import', ['model' => TypesenseSearchableModel::class]) + ->assertOk(); + + // Verify models are indexed + $results = TypesenseSearchableModel::search('')->get(); + $this->assertCount(2, $results); + + // Run the flush command + $this->artisan('scout:flush', ['model' => TypesenseSearchableModel::class]) + ->assertOk(); + + // Verify models are removed from the index (collection is deleted) + $results = TypesenseSearchableModel::search('')->get(); + $this->assertCount(0, $results); + } +} diff --git a/tests/Scout/Integration/Typesense/TypesenseConfigIntegrationTest.php b/tests/Scout/Integration/Typesense/TypesenseConfigIntegrationTest.php new file mode 100644 index 000000000..1e5d36971 --- /dev/null +++ b/tests/Scout/Integration/Typesense/TypesenseConfigIntegrationTest.php @@ -0,0 +1,197 @@ +app->get(ConfigInterface::class)->set("scout.typesense.model-settings.{$modelClass}", [ + 'collection-schema' => [ + 'fields' => [ + ['name' => 'id', 'type' => 'string'], + ['name' => 'title', 'type' => 'string'], + ['name' => 'body', 'type' => 'string'], + ], + ], + 'search-parameters' => [ + 'query_by' => 'title,body', + ], + ]); + + // Clear cached engines to pick up new config + $this->app->get(EngineManager::class)->forgetEngines(); + + // Create a model - this should use the config-based schema + $model = ConfigBasedTypesenseModel::create(['title' => 'Test Title', 'body' => 'Test Body']); + + // Index it + $this->engine->update(new EloquentCollection([$model])); + + // Verify we can search for it (proves schema and search params work) + $results = ConfigBasedTypesenseModel::search('Test')->get(); + + $this->assertCount(1, $results); + $this->assertSame($model->id, $results->first()->id); + } + + public function testModelSettingsSearchParametersFromConfig(): void + { + $modelClass = ConfigBasedTypesenseModel::class; + + // Configure with specific query_by that only searches title + $this->app->get(ConfigInterface::class)->set("scout.typesense.model-settings.{$modelClass}", [ + 'collection-schema' => [ + 'fields' => [ + ['name' => 'id', 'type' => 'string'], + ['name' => 'title', 'type' => 'string'], + ['name' => 'body', 'type' => 'string'], + ], + ], + 'search-parameters' => [ + 'query_by' => 'title', // Only search title, not body + ], + ]); + + // Clear cached engines + $this->app->get(EngineManager::class)->forgetEngines(); + + // Create models + $model1 = ConfigBasedTypesenseModel::create(['title' => 'Unique Word', 'body' => 'Common']); + $model2 = ConfigBasedTypesenseModel::create(['title' => 'Common', 'body' => 'Unique Word']); + + // Index them + $this->engine->update(new EloquentCollection([$model1, $model2])); + + // Search for "Unique" - should only find model1 since query_by is just title + $results = ConfigBasedTypesenseModel::search('Unique')->get(); + + $this->assertCount(1, $results); + $this->assertSame($model1->id, $results->first()->id); + } + + public function testMaxTotalResultsConfigLimitsPagination(): void + { + $modelClass = ConfigBasedTypesenseModel::class; + + // Configure collection schema + $this->app->get(ConfigInterface::class)->set("scout.typesense.model-settings.{$modelClass}", [ + 'collection-schema' => [ + 'fields' => [ + ['name' => 'id', 'type' => 'string'], + ['name' => 'title', 'type' => 'string'], + ['name' => 'body', 'type' => 'string'], + ], + ], + 'search-parameters' => [ + 'query_by' => 'title,body', + ], + ]); + + // Set max_total_results to a low value + $this->app->get(ConfigInterface::class)->set('scout.typesense.max_total_results', 3); + + // Clear cached engines to pick up new config + $this->app->get(EngineManager::class)->forgetEngines(); + $this->engine = $this->app->get(EngineManager::class)->engine('typesense'); + + // Create 5 models + for ($i = 1; $i <= 5; ++$i) { + $model = ConfigBasedTypesenseModel::create(['title' => "Model {$i}", 'body' => 'Content']); + $this->engine->update(new EloquentCollection([$model])); + } + + // Search without limit - should be capped at max_total_results + $results = ConfigBasedTypesenseModel::search('')->get(); + + $this->assertLessThanOrEqual(3, $results->count()); + } + + protected function setUpInCoroutine(): void + { + parent::setUpInCoroutine(); + + // Clean up any existing collection for ConfigBasedTypesenseModel + $this->cleanupConfigBasedCollection(); + } + + protected function tearDownInCoroutine(): void + { + $this->cleanupConfigBasedCollection(); + + parent::tearDownInCoroutine(); + } + + public function testImportActionConfigIsUsed(): void + { + $modelClass = ConfigBasedTypesenseModel::class; + + // Configure collection schema + $this->app->get(ConfigInterface::class)->set("scout.typesense.model-settings.{$modelClass}", [ + 'collection-schema' => [ + 'fields' => [ + ['name' => 'id', 'type' => 'string'], + ['name' => 'title', 'type' => 'string'], + ['name' => 'body', 'type' => 'string'], + ], + ], + 'search-parameters' => [ + 'query_by' => 'title,body', + ], + ]); + + // Set import_action to 'upsert' (default) - allows insert and update + $this->app->get(ConfigInterface::class)->set('scout.typesense.import_action', 'upsert'); + + // Clear cached engines + $this->app->get(EngineManager::class)->forgetEngines(); + + // Create and index a model + $model = ConfigBasedTypesenseModel::create(['title' => 'Original Title', 'body' => 'Content']); + $this->engine->update(new EloquentCollection([$model])); + + // Verify it's indexed + $results = ConfigBasedTypesenseModel::search('Original')->get(); + $this->assertCount(1, $results); + + // Update the model and re-index (upsert should allow this) + $model->title = 'Updated Title'; + $model->save(); + $this->engine->update(new EloquentCollection([$model])); + + // Verify the update worked + $results = ConfigBasedTypesenseModel::search('Updated')->get(); + $this->assertCount(1, $results); + $this->assertSame('Updated Title', $results->first()->title); + } + + private function cleanupConfigBasedCollection(): void + { + try { + $collectionName = $this->testPrefix . 'config_based_typesense_models'; + $this->typesense->collections[$collectionName]->delete(); + } catch (Throwable) { + // Collection doesn't exist, that's fine + } + } +} diff --git a/tests/Scout/Integration/Typesense/TypesenseConnectionTest.php b/tests/Scout/Integration/Typesense/TypesenseConnectionTest.php new file mode 100644 index 000000000..82d088f98 --- /dev/null +++ b/tests/Scout/Integration/Typesense/TypesenseConnectionTest.php @@ -0,0 +1,87 @@ +initializeTypesense(); + } + + public function testCanConnectToTypesense(): void + { + $health = $this->typesense->health->retrieve(); + + $this->assertTrue($health['ok']); + } + + public function testCanCreateAndDeleteCollection(): void + { + $collectionName = $this->prefixedCollectionName('test_collection'); + + // Create collection + $this->typesense->collections->create([ + 'name' => $collectionName, + 'fields' => [ + ['name' => 'id', 'type' => 'string'], + ['name' => 'title', 'type' => 'string'], + ], + ]); + + // Verify it exists + $collection = $this->typesense->collections[$collectionName]->retrieve(); + $this->assertSame($collectionName, $collection['name']); + + // Delete it + $this->typesense->collections[$collectionName]->delete(); + } + + public function testCanIndexAndSearchDocuments(): void + { + $collectionName = $this->prefixedCollectionName('search_test'); + + // Create collection + $this->typesense->collections->create([ + 'name' => $collectionName, + 'fields' => [ + ['name' => 'id', 'type' => 'string'], + ['name' => 'title', 'type' => 'string'], + ], + ]); + + $collection = $this->typesense->collections[$collectionName]; + + // Add documents + $collection->documents->create(['id' => '1', 'title' => 'Hello World']); + $collection->documents->create(['id' => '2', 'title' => 'Goodbye World']); + + // Search + $results = $collection->documents->search([ + 'q' => 'Hello', + 'query_by' => 'title', + ]); + + $this->assertSame(1, $results['found']); + $this->assertSame('Hello World', $results['hits'][0]['document']['title']); + + // Cleanup + $this->typesense->collections[$collectionName]->delete(); + } +} diff --git a/tests/Scout/Integration/Typesense/TypesenseEngineIntegrationTest.php b/tests/Scout/Integration/Typesense/TypesenseEngineIntegrationTest.php new file mode 100644 index 000000000..016b916b8 --- /dev/null +++ b/tests/Scout/Integration/Typesense/TypesenseEngineIntegrationTest.php @@ -0,0 +1,190 @@ + 'Test Document', 'body' => 'Content here']); + + $this->engine->update(new EloquentCollection([$model])); + + $results = $this->typesense->collections[$model->searchableAs()]->documents->search([ + 'q' => 'Test', + 'query_by' => 'title', + ]); + + $this->assertSame(1, $results['found']); + $this->assertSame('Test Document', $results['hits'][0]['document']['title']); + } + + public function testUpdateWithMultipleModels(): void + { + $models = new EloquentCollection([ + TypesenseSearchableModel::create(['title' => 'First', 'body' => 'Body 1']), + TypesenseSearchableModel::create(['title' => 'Second', 'body' => 'Body 2']), + TypesenseSearchableModel::create(['title' => 'Third', 'body' => 'Body 3']), + ]); + + $this->engine->update($models); + + $results = $this->typesense->collections[$models->first()->searchableAs()]->documents->search([ + 'q' => '*', + 'query_by' => 'title', + ]); + + $this->assertSame(3, $results['found']); + } + + public function testDeleteRemovesModelsFromTypesense(): void + { + $model = TypesenseSearchableModel::create(['title' => 'To Delete', 'body' => 'Content']); + + $this->engine->update(new EloquentCollection([$model])); + + // Verify it exists + $results = $this->typesense->collections[$model->searchableAs()]->documents->search([ + 'q' => 'Delete', + 'query_by' => 'title', + ]); + $this->assertSame(1, $results['found']); + + // Delete it + $this->engine->delete(new EloquentCollection([$model])); + + // Verify it's gone + $results = $this->typesense->collections[$model->searchableAs()]->documents->search([ + 'q' => 'Delete', + 'query_by' => 'title', + ]); + $this->assertSame(0, $results['found']); + } + + public function testSearchReturnsMatchingResults(): void + { + TypesenseSearchableModel::create(['title' => 'PHP Programming', 'body' => 'Learn PHP']); + TypesenseSearchableModel::create(['title' => 'JavaScript Guide', 'body' => 'Learn JS']); + TypesenseSearchableModel::create(['title' => 'PHP Best Practices', 'body' => 'Advanced PHP']); + + TypesenseSearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + + $results = TypesenseSearchableModel::search('PHP')->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains('title', 'PHP Programming')); + $this->assertTrue($results->contains('title', 'PHP Best Practices')); + } + + public function testSearchWithEmptyQueryReturnsAllDocuments(): void + { + TypesenseSearchableModel::create(['title' => 'First', 'body' => 'Body']); + TypesenseSearchableModel::create(['title' => 'Second', 'body' => 'Body']); + + TypesenseSearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + + $results = TypesenseSearchableModel::search('')->get(); + + $this->assertCount(2, $results); + } + + public function testPaginateReturnsCorrectPage(): void + { + for ($i = 1; $i <= 10; ++$i) { + TypesenseSearchableModel::create(['title' => "Item {$i}", 'body' => 'Body']); + } + + TypesenseSearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + + $page1 = TypesenseSearchableModel::search('')->paginate(3, 'page', 1); + $page2 = TypesenseSearchableModel::search('')->paginate(3, 'page', 2); + + $this->assertCount(3, $page1); + $this->assertCount(3, $page2); + $this->assertSame(10, $page1->total()); + } + + public function testFlushRemovesAllDocumentsFromCollection(): void + { + $models = new EloquentCollection([ + TypesenseSearchableModel::create(['title' => 'First', 'body' => 'Body']), + TypesenseSearchableModel::create(['title' => 'Second', 'body' => 'Body']), + ]); + + $this->engine->update($models); + + // Verify documents exist + $results = $this->typesense->collections[$models->first()->searchableAs()]->documents->search([ + 'q' => '*', + 'query_by' => 'title', + ]); + $this->assertSame(2, $results['found']); + + // Flush + $this->engine->flush($models->first()); + + // Verify collection is deleted (Typesense flush deletes the collection) + $this->expectException(\Typesense\Exceptions\ObjectNotFound::class); + $this->typesense->collections[$models->first()->searchableAs()]->retrieve(); + } + + public function testGetTotalCountReturnsCorrectCount(): void + { + for ($i = 1; $i <= 5; ++$i) { + TypesenseSearchableModel::create(['title' => "Item {$i}", 'body' => 'Body']); + } + + TypesenseSearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + + $builder = TypesenseSearchableModel::search(''); + $results = $this->engine->search($builder); + + $this->assertSame(5, $this->engine->getTotalCount($results)); + } + + public function testMapIdsReturnsCollectionOfIds(): void + { + $models = new EloquentCollection([ + TypesenseSearchableModel::create(['title' => 'First', 'body' => 'Body']), + TypesenseSearchableModel::create(['title' => 'Second', 'body' => 'Body']), + ]); + + $this->engine->update($models); + + $builder = TypesenseSearchableModel::search(''); + $results = $this->engine->search($builder); + $ids = $this->engine->mapIds($results); + + $this->assertCount(2, $ids); + $this->assertTrue($ids->contains((string) $models[0]->id)); + $this->assertTrue($ids->contains((string) $models[1]->id)); + } + + public function testKeysReturnsScoutKeys(): void + { + $models = new EloquentCollection([ + TypesenseSearchableModel::create(['title' => 'First', 'body' => 'Body']), + TypesenseSearchableModel::create(['title' => 'Second', 'body' => 'Body']), + ]); + + $this->engine->update($models); + + $keys = TypesenseSearchableModel::search('')->keys(); + + $this->assertCount(2, $keys); + } +} diff --git a/tests/Scout/Integration/Typesense/TypesenseFilteringIntegrationTest.php b/tests/Scout/Integration/Typesense/TypesenseFilteringIntegrationTest.php new file mode 100644 index 000000000..cc613f69c --- /dev/null +++ b/tests/Scout/Integration/Typesense/TypesenseFilteringIntegrationTest.php @@ -0,0 +1,122 @@ + 'PHP Guide', 'body' => 'Learn PHP']); + TypesenseSearchableModel::create(['title' => 'JavaScript Guide', 'body' => 'Learn JS']); + TypesenseSearchableModel::create(['title' => 'PHP Advanced', 'body' => 'Advanced PHP']); + + TypesenseSearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + + $results = TypesenseSearchableModel::search('') + ->where('title', 'PHP Guide') + ->get(); + + $this->assertCount(1, $results); + $this->assertSame('PHP Guide', $results->first()->title); + } + + public function testWhereWithNumericIdAsString(): void + { + $model1 = TypesenseSearchableModel::create(['title' => 'First', 'body' => 'Body']); + $model2 = TypesenseSearchableModel::create(['title' => 'Second', 'body' => 'Body']); + + TypesenseSearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + + $results = TypesenseSearchableModel::search('') + ->where('id', (string) $model1->id) + ->get(); + + $this->assertCount(1, $results); + $this->assertSame($model1->id, $results->first()->id); + } + + public function testWhereInFiltersResultsByMultipleValues(): void + { + $model1 = TypesenseSearchableModel::create(['title' => 'First', 'body' => 'Body']); + $model2 = TypesenseSearchableModel::create(['title' => 'Second', 'body' => 'Body']); + $model3 = TypesenseSearchableModel::create(['title' => 'Third', 'body' => 'Body']); + + TypesenseSearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + + $results = TypesenseSearchableModel::search('') + ->whereIn('id', [(string) $model1->id, (string) $model3->id]) + ->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains('id', $model1->id)); + $this->assertTrue($results->contains('id', $model3->id)); + $this->assertFalse($results->contains('id', $model2->id)); + } + + public function testWhereNotInExcludesSpecifiedValues(): void + { + $model1 = TypesenseSearchableModel::create(['title' => 'First', 'body' => 'Body']); + $model2 = TypesenseSearchableModel::create(['title' => 'Second', 'body' => 'Body']); + $model3 = TypesenseSearchableModel::create(['title' => 'Third', 'body' => 'Body']); + + TypesenseSearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + + $results = TypesenseSearchableModel::search('') + ->whereNotIn('id', [(string) $model1->id, (string) $model3->id]) + ->get(); + + $this->assertCount(1, $results); + $this->assertSame($model2->id, $results->first()->id); + } + + public function testMultipleWhereClausesAreCombinedWithAnd(): void + { + TypesenseSearchableModel::create(['title' => 'PHP Guide', 'body' => 'Content A']); + TypesenseSearchableModel::create(['title' => 'PHP Guide', 'body' => 'Content B']); + TypesenseSearchableModel::create(['title' => 'JS Guide', 'body' => 'Content A']); + + TypesenseSearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + + $results = TypesenseSearchableModel::search('') + ->where('title', 'PHP Guide') + ->where('body', 'Content A') + ->get(); + + $this->assertCount(1, $results); + $this->assertSame('PHP Guide', $results->first()->title); + $this->assertSame('Content A', $results->first()->body); + } + + public function testCombinedWhereAndWhereIn(): void + { + $model1 = TypesenseSearchableModel::create(['title' => 'PHP', 'body' => 'A']); + $model2 = TypesenseSearchableModel::create(['title' => 'PHP', 'body' => 'B']); + $model3 = TypesenseSearchableModel::create(['title' => 'JS', 'body' => 'A']); + $model4 = TypesenseSearchableModel::create(['title' => 'PHP', 'body' => 'C']); + + TypesenseSearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + + $results = TypesenseSearchableModel::search('') + ->where('title', 'PHP') + ->whereIn('body', ['A', 'B']) + ->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains('id', $model1->id)); + $this->assertTrue($results->contains('id', $model2->id)); + } +} diff --git a/tests/Scout/Integration/Typesense/TypesenseScoutIntegrationTestCase.php b/tests/Scout/Integration/Typesense/TypesenseScoutIntegrationTestCase.php new file mode 100644 index 000000000..8896c0825 --- /dev/null +++ b/tests/Scout/Integration/Typesense/TypesenseScoutIntegrationTestCase.php @@ -0,0 +1,88 @@ +registerScoutCommands(); + + // Clear cached engines so they're recreated with our test config + $this->app->get(EngineManager::class)->forgetEngines(); + } + + protected function setUpInCoroutine(): void + { + $this->initializeTypesense(); + $this->engine = $this->app->get(EngineManager::class)->engine('typesense'); + } + + protected function tearDownInCoroutine(): void + { + $this->cleanupTestCollections(); + } + + /** + * Register Scout commands with the Artisan application. + */ + protected function registerScoutCommands(): void + { + Artisan::getArtisan()->resolveCommands([ + DeleteIndexCommand::class, + FlushCommand::class, + ImportCommand::class, + IndexCommand::class, + SyncIndexSettingsCommand::class, + ]); + } + + protected function migrateFreshUsing(): array + { + return [ + '--seed' => $this->shouldSeed(), + '--database' => $this->getRefreshConnection(), + '--realpath' => true, + '--path' => [ + dirname(__DIR__, 2) . '/migrations', + ], + ]; + } +} diff --git a/tests/Scout/Integration/Typesense/TypesenseSoftDeleteIntegrationTest.php b/tests/Scout/Integration/Typesense/TypesenseSoftDeleteIntegrationTest.php new file mode 100644 index 000000000..dda676687 --- /dev/null +++ b/tests/Scout/Integration/Typesense/TypesenseSoftDeleteIntegrationTest.php @@ -0,0 +1,101 @@ +app->get(ConfigInterface::class)->set('scout.soft_delete', true); + } + + public function testDefaultSearchExcludesSoftDeletedModels(): void + { + $model1 = TypesenseSoftDeleteSearchableModel::create(['title' => 'Active One', 'body' => 'Content']); + $model2 = TypesenseSoftDeleteSearchableModel::create(['title' => 'Active Two', 'body' => 'Content']); + $model3 = TypesenseSoftDeleteSearchableModel::create(['title' => 'Deleted One', 'body' => 'Content']); + + // Index all models + TypesenseSoftDeleteSearchableModel::withTrashed()->get()->each( + fn ($m) => $this->engine->update(new EloquentCollection([$m])) + ); + + // Soft delete one model and re-index it + $model3->delete(); + $this->engine->update(new EloquentCollection([$model3->fresh()])); + + // Default search should exclude soft-deleted model + $results = TypesenseSoftDeleteSearchableModel::search('')->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains('id', $model1->id)); + $this->assertTrue($results->contains('id', $model2->id)); + $this->assertFalse($results->contains('id', $model3->id)); + } + + public function testWithTrashedIncludesSoftDeletedModels(): void + { + $model1 = TypesenseSoftDeleteSearchableModel::create(['title' => 'Active', 'body' => 'Content']); + $model2 = TypesenseSoftDeleteSearchableModel::create(['title' => 'Deleted', 'body' => 'Content']); + + // Index all models + TypesenseSoftDeleteSearchableModel::withTrashed()->get()->each( + fn ($m) => $this->engine->update(new EloquentCollection([$m])) + ); + + // Soft delete one model and re-index it + $model2->delete(); + $this->engine->update(new EloquentCollection([$model2->fresh()])); + + // withTrashed should include soft-deleted model + $results = TypesenseSoftDeleteSearchableModel::search('')->withTrashed()->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains('id', $model1->id)); + $this->assertTrue($results->contains('id', $model2->id)); + } + + public function testOnlyTrashedReturnsOnlySoftDeletedModels(): void + { + $model1 = TypesenseSoftDeleteSearchableModel::create(['title' => 'Active', 'body' => 'Content']); + $model2 = TypesenseSoftDeleteSearchableModel::create(['title' => 'Deleted One', 'body' => 'Content']); + $model3 = TypesenseSoftDeleteSearchableModel::create(['title' => 'Deleted Two', 'body' => 'Content']); + + // Index all models + TypesenseSoftDeleteSearchableModel::withTrashed()->get()->each( + fn ($m) => $this->engine->update(new EloquentCollection([$m])) + ); + + // Soft delete two models and re-index them + $model2->delete(); + $model3->delete(); + $this->engine->update(new EloquentCollection([$model2->fresh()])); + $this->engine->update(new EloquentCollection([$model3->fresh()])); + + // onlyTrashed should return only soft-deleted models + $results = TypesenseSoftDeleteSearchableModel::search('')->onlyTrashed()->get(); + + $this->assertCount(2, $results); + $this->assertFalse($results->contains('id', $model1->id)); + $this->assertTrue($results->contains('id', $model2->id)); + $this->assertTrue($results->contains('id', $model3->id)); + } +} diff --git a/tests/Scout/Integration/Typesense/TypesenseSortingIntegrationTest.php b/tests/Scout/Integration/Typesense/TypesenseSortingIntegrationTest.php new file mode 100644 index 000000000..16559492f --- /dev/null +++ b/tests/Scout/Integration/Typesense/TypesenseSortingIntegrationTest.php @@ -0,0 +1,91 @@ + 'Charlie', 'body' => 'Body']); + TypesenseSearchableModel::create(['title' => 'Alpha', 'body' => 'Body']); + TypesenseSearchableModel::create(['title' => 'Bravo', 'body' => 'Body']); + + TypesenseSearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + + $results = TypesenseSearchableModel::search('') + ->orderBy('title', 'asc') + ->get(); + + $this->assertCount(3, $results); + $this->assertSame('Alpha', $results[0]->title); + $this->assertSame('Bravo', $results[1]->title); + $this->assertSame('Charlie', $results[2]->title); + } + + public function testOrderByDescendingSortsResultsCorrectly(): void + { + TypesenseSearchableModel::create(['title' => 'Alpha', 'body' => 'Body']); + TypesenseSearchableModel::create(['title' => 'Bravo', 'body' => 'Body']); + TypesenseSearchableModel::create(['title' => 'Charlie', 'body' => 'Body']); + + TypesenseSearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + + $results = TypesenseSearchableModel::search('') + ->orderBy('title', 'desc') + ->get(); + + $this->assertCount(3, $results); + $this->assertSame('Charlie', $results[0]->title); + $this->assertSame('Bravo', $results[1]->title); + $this->assertSame('Alpha', $results[2]->title); + } + + public function testOrderByDescHelperMethod(): void + { + TypesenseSearchableModel::create(['title' => 'Alpha', 'body' => 'Body']); + TypesenseSearchableModel::create(['title' => 'Bravo', 'body' => 'Body']); + + TypesenseSearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + + $results = TypesenseSearchableModel::search('') + ->orderByDesc('title') + ->get(); + + $this->assertCount(2, $results); + $this->assertSame('Bravo', $results[0]->title); + $this->assertSame('Alpha', $results[1]->title); + } + + public function testMultipleSortFields(): void + { + TypesenseSearchableModel::create(['title' => 'Alpha', 'body' => 'Content']); + TypesenseSearchableModel::create(['title' => 'Alpha', 'body' => 'Content']); + TypesenseSearchableModel::create(['title' => 'Bravo', 'body' => 'Content']); + + TypesenseSearchableModel::query()->get()->each(fn ($m) => $this->engine->update(new EloquentCollection([$m]))); + + $results = TypesenseSearchableModel::search('') + ->orderBy('title', 'asc') + ->get(); + + $this->assertCount(3, $results); + // First two should be Alpha (order between them is undefined) + $this->assertSame('Alpha', $results[0]->title); + $this->assertSame('Alpha', $results[1]->title); + $this->assertSame('Bravo', $results[2]->title); + } +} diff --git a/tests/Scout/Models/ConditionalSearchableModel.php b/tests/Scout/Models/ConditionalSearchableModel.php new file mode 100644 index 000000000..0e2e597eb --- /dev/null +++ b/tests/Scout/Models/ConditionalSearchableModel.php @@ -0,0 +1,38 @@ +title ?? '', 'hidden'); + } + + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'body' => $this->body, + ]; + } +} diff --git a/tests/Scout/Models/ConfigBasedTypesenseModel.php b/tests/Scout/Models/ConfigBasedTypesenseModel.php new file mode 100644 index 000000000..a1f7aad2a --- /dev/null +++ b/tests/Scout/Models/ConfigBasedTypesenseModel.php @@ -0,0 +1,33 @@ + (string) $this->id, + 'title' => $this->title, + 'body' => $this->body ?? '', + ]; + } +} diff --git a/tests/Scout/Models/CustomScoutKeyModel.php b/tests/Scout/Models/CustomScoutKeyModel.php new file mode 100644 index 000000000..8041f28cb --- /dev/null +++ b/tests/Scout/Models/CustomScoutKeyModel.php @@ -0,0 +1,43 @@ +id; + } + + public function getScoutKeyName(): string + { + return 'id'; + } + + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'body' => $this->body, + ]; + } +} diff --git a/tests/Scout/Models/FilteringSearchableModel.php b/tests/Scout/Models/FilteringSearchableModel.php new file mode 100644 index 000000000..a57677beb --- /dev/null +++ b/tests/Scout/Models/FilteringSearchableModel.php @@ -0,0 +1,44 @@ + $models + * @return Collection + */ + public function makeSearchableUsing(Collection $models): Collection + { + return $models->filter(function ($model) { + return ! str_starts_with($model->title ?? '', 'Draft:'); + })->values(); + } + + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'body' => $this->body, + ]; + } +} diff --git a/tests/Scout/Models/PrefixSearchableModel.php b/tests/Scout/Models/PrefixSearchableModel.php new file mode 100644 index 000000000..d7dd8191d --- /dev/null +++ b/tests/Scout/Models/PrefixSearchableModel.php @@ -0,0 +1,32 @@ + $this->id, + 'title' => $this->title, + 'body' => $this->body, + ]; + } +} diff --git a/tests/Scout/Models/SearchableModel.php b/tests/Scout/Models/SearchableModel.php new file mode 100644 index 000000000..d322c4b76 --- /dev/null +++ b/tests/Scout/Models/SearchableModel.php @@ -0,0 +1,30 @@ + $this->id, + 'title' => $this->title, + 'body' => $this->body, + ]; + } +} diff --git a/tests/Scout/Models/SoftDeletableSearchableModel.php b/tests/Scout/Models/SoftDeletableSearchableModel.php new file mode 100644 index 000000000..971f1b015 --- /dev/null +++ b/tests/Scout/Models/SoftDeletableSearchableModel.php @@ -0,0 +1,32 @@ + $this->id, + 'title' => $this->title, + 'body' => $this->body, + ]; + } +} diff --git a/tests/Scout/Models/SoftDeleteSearchableModel.php b/tests/Scout/Models/SoftDeleteSearchableModel.php new file mode 100644 index 000000000..0baeea8bc --- /dev/null +++ b/tests/Scout/Models/SoftDeleteSearchableModel.php @@ -0,0 +1,32 @@ + $this->id, + 'title' => $this->title, + 'body' => $this->body ?? '', + ]; + } +} diff --git a/tests/Scout/Models/TypesenseSearchableModel.php b/tests/Scout/Models/TypesenseSearchableModel.php new file mode 100644 index 000000000..741b616ea --- /dev/null +++ b/tests/Scout/Models/TypesenseSearchableModel.php @@ -0,0 +1,61 @@ + (string) $this->id, + 'title' => $this->title, + 'body' => $this->body ?? '', + ]; + } + + /** + * Get the Typesense collection schema. + * + * @return array + */ + public function typesenseCollectionSchema(): array + { + return [ + 'name' => $this->searchableAs(), + 'fields' => [ + ['name' => 'id', 'type' => 'string', 'facet' => true], + ['name' => 'title', 'type' => 'string', 'facet' => true, 'sort' => true], + ['name' => 'body', 'type' => 'string', 'facet' => true], + ], + ]; + } + + /** + * Get the Typesense search parameters. + * + * @return array + */ + public function typesenseSearchParameters(): array + { + return [ + 'query_by' => 'title,body', + ]; + } +} diff --git a/tests/Scout/Models/TypesenseSoftDeleteSearchableModel.php b/tests/Scout/Models/TypesenseSoftDeleteSearchableModel.php new file mode 100644 index 000000000..e1d9c6c4d --- /dev/null +++ b/tests/Scout/Models/TypesenseSoftDeleteSearchableModel.php @@ -0,0 +1,64 @@ + (string) $this->id, + 'title' => $this->title, + 'body' => $this->body ?? '', + ]; + } + + /** + * Get the Typesense collection schema. + * + * @return array + */ + public function typesenseCollectionSchema(): array + { + return [ + 'name' => $this->searchableAs(), + 'fields' => [ + ['name' => 'id', 'type' => 'string', 'facet' => true], + ['name' => 'title', 'type' => 'string', 'facet' => true, 'sort' => true], + ['name' => 'body', 'type' => 'string', 'facet' => true], + ['name' => '__soft_deleted', 'type' => 'int32', 'facet' => true], + ], + ]; + } + + /** + * Get the Typesense search parameters. + * + * @return array + */ + public function typesenseSearchParameters(): array + { + return [ + 'query_by' => 'title,body', + ]; + } +} diff --git a/tests/Scout/ScoutTestCase.php b/tests/Scout/ScoutTestCase.php new file mode 100644 index 000000000..bb1a8ad14 --- /dev/null +++ b/tests/Scout/ScoutTestCase.php @@ -0,0 +1,65 @@ +app->register(ScoutServiceProvider::class); + + $this->app->get(ConfigInterface::class) + ->set('scout', [ + 'driver' => 'collection', + 'prefix' => '', + 'queue' => [ + 'enabled' => false, + 'connection' => null, + 'queue' => null, + 'after_commit' => false, + ], + 'soft_delete' => false, + 'chunk' => [ + 'searchable' => 500, + 'unsearchable' => 500, + ], + 'command_concurrency' => 100, + ]); + } + + protected function migrateFreshUsing(): array + { + return [ + '--seed' => $this->shouldSeed(), + '--database' => $this->getRefreshConnection(), + '--realpath' => true, + '--path' => [ + __DIR__ . '/migrations', + ], + ]; + } +} diff --git a/tests/Scout/Unit/BuilderTest.php b/tests/Scout/Unit/BuilderTest.php new file mode 100644 index 000000000..1f3e4d111 --- /dev/null +++ b/tests/Scout/Unit/BuilderTest.php @@ -0,0 +1,602 @@ +assertSame($model, $builder->model); + $this->assertSame('test query', $builder->query); + } + + public function testWhereAddsConstraint() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + $result = $builder->where('status', 'active'); + + $this->assertSame($builder, $result); + $this->assertSame(['status' => 'active'], $builder->wheres); + } + + public function testWhereInAddsConstraint() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + $result = $builder->whereIn('id', [1, 2, 3]); + + $this->assertSame($builder, $result); + $this->assertSame(['id' => [1, 2, 3]], $builder->whereIns); + } + + public function testWhereInAcceptsArrayable() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + // Use an array directly as Collection may not implement Arrayable + $result = $builder->whereIn('id', [1, 2, 3]); + + $this->assertSame($builder, $result); + $this->assertSame(['id' => [1, 2, 3]], $builder->whereIns); + } + + public function testWhereNotInAddsConstraint() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + $result = $builder->whereNotIn('id', [4, 5, 6]); + + $this->assertSame($builder, $result); + $this->assertSame(['id' => [4, 5, 6]], $builder->whereNotIns); + } + + public function testWithinSetsCustomIndex() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + $result = $builder->within('custom_index'); + + $this->assertSame($builder, $result); + $this->assertSame('custom_index', $builder->index); + } + + public function testTakeSetsLimit() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + $result = $builder->take(100); + + $this->assertSame($builder, $result); + $this->assertSame(100, $builder->limit); + } + + public function testOrderByAddsOrder() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + $result = $builder->orderBy('name', 'asc'); + + $this->assertSame($builder, $result); + $this->assertSame([['column' => 'name', 'direction' => 'asc']], $builder->orders); + } + + public function testOrderByNormalizesDirection() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + $builder->orderBy('name', 'ASC'); + + $this->assertSame([['column' => 'name', 'direction' => 'asc']], $builder->orders); + } + + public function testOrderByDescAddsDescendingOrder() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + $result = $builder->orderByDesc('name'); + + $this->assertSame($builder, $result); + $this->assertSame([['column' => 'name', 'direction' => 'desc']], $builder->orders); + } + + public function testLatestOrdersByCreatedAtDesc() + { + $model = m::mock(Model::class); + $model->shouldReceive('getCreatedAtColumn')->andReturn('created_at'); + + $builder = new Builder($model, 'query'); + $result = $builder->latest(); + + $this->assertSame($builder, $result); + $this->assertSame([['column' => 'created_at', 'direction' => 'desc']], $builder->orders); + } + + public function testLatestWithCustomColumn() + { + $model = m::mock(Model::class); + + $builder = new Builder($model, 'query'); + $result = $builder->latest('updated_at'); + + $this->assertSame($builder, $result); + $this->assertSame([['column' => 'updated_at', 'direction' => 'desc']], $builder->orders); + } + + public function testOldestOrdersByCreatedAtAsc() + { + $model = m::mock(Model::class); + $model->shouldReceive('getCreatedAtColumn')->andReturn('created_at'); + + $builder = new Builder($model, 'query'); + $result = $builder->oldest(); + + $this->assertSame($builder, $result); + $this->assertSame([['column' => 'created_at', 'direction' => 'asc']], $builder->orders); + } + + public function testOptionsSetsOptions() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + $result = $builder->options(['highlight' => true]); + + $this->assertSame($builder, $result); + $this->assertSame(['highlight' => true], $builder->options); + } + + public function testQuerySetsQueryCallback() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + $callback = fn () => 'test'; + $result = $builder->query($callback); + + $this->assertSame($builder, $result); + $this->assertNotNull($builder->queryCallback); + } + + public function testWithRawResultsSetsCallback() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + $callback = fn ($results) => $results; + $result = $builder->withRawResults($callback); + + $this->assertSame($builder, $result); + $this->assertNotNull($builder->afterRawSearchCallback); + } + + public function testSoftDeleteSetsSoftDeleteWhere() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query', null, softDelete: true); + + $this->assertSame(0, $builder->wheres['__soft_deleted']); + } + + public function testHardDeleteDoesNotSetSoftDeleteWhere() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query', null, softDelete: false); + + $this->assertArrayNotHasKey('__soft_deleted', $builder->wheres); + } + + public function testWithTrashedRemovesSoftDeleteWhere() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query', null, softDelete: true); + + $this->assertSame(0, $builder->wheres['__soft_deleted']); + + $result = $builder->withTrashed(); + + $this->assertSame($builder, $result); + $this->assertArrayNotHasKey('__soft_deleted', $builder->wheres); + } + + public function testOnlyTrashedSetsSoftDeleteWhereToOne() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query', null, softDelete: true); + + $result = $builder->onlyTrashed(); + + $this->assertSame($builder, $result); + $this->assertSame(1, $builder->wheres['__soft_deleted']); + } + + public function testRawCallsEngineSearch() + { + $model = m::mock(Model::class); + $engine = m::mock(Engine::class); + $model->shouldReceive('searchableUsing')->andReturn($engine); + + $engine->shouldReceive('search') + ->once() + ->andReturn(['hits' => [], 'totalHits' => 0]); + + $builder = new Builder($model, 'query'); + + $result = $builder->raw(); + + $this->assertEquals(['hits' => [], 'totalHits' => 0], $result); + } + + public function testKeysCallsEngineKeys() + { + $model = m::mock(Model::class); + $engine = m::mock(Engine::class); + $model->shouldReceive('searchableUsing')->andReturn($engine); + + $engine->shouldReceive('keys') + ->once() + ->andReturn(new Collection([1, 2, 3])); + + $builder = new Builder($model, 'query'); + + $result = $builder->keys(); + + $this->assertEquals([1, 2, 3], $result->all()); + } + + public function testGetCallsEngineGet() + { + $model = m::mock(Model::class); + $engine = m::mock(Engine::class); + $model->shouldReceive('searchableUsing')->andReturn($engine); + + $engine->shouldReceive('get') + ->once() + ->andReturn(new EloquentCollection([m::mock(Model::class)])); + + $builder = new Builder($model, 'query'); + + $result = $builder->get(); + + $this->assertInstanceOf(EloquentCollection::class, $result); + $this->assertCount(1, $result); + } + + public function testFirstReturnsFirstResult() + { + $model = m::mock(Model::class); + $engine = m::mock(Engine::class); + $model->shouldReceive('searchableUsing')->andReturn($engine); + + $firstModel = m::mock(Model::class); + + $engine->shouldReceive('get') + ->once() + ->andReturn(new EloquentCollection([$firstModel])); + + $builder = new Builder($model, 'query'); + + $result = $builder->first(); + + $this->assertSame($firstModel, $result); + } + + public function testFirstReturnsNullWhenNoResults() + { + $model = m::mock(Model::class); + $engine = m::mock(Engine::class); + $model->shouldReceive('searchableUsing')->andReturn($engine); + + $engine->shouldReceive('get') + ->once() + ->andReturn(new EloquentCollection([])); + + $builder = new Builder($model, 'query'); + + $result = $builder->first(); + + $this->assertNull($result); + } + + public function testPaginationCorrectlyHandlesPaginatedResults() + { + Paginator::currentPageResolver(function () { + return 1; + }); + Paginator::currentPathResolver(function () { + return 'http://localhost/foo'; + }); + + $model = m::mock(Model::class); + $model->shouldReceive('getPerPage')->andReturn(15); + $model->shouldReceive('searchableUsing')->andReturn($engine = m::mock(Engine::class)); + $model->shouldReceive('getScoutKeyName')->andReturn('id'); + + // Create collection manually instead of using times() + $items = []; + for ($i = 0; $i < 15; ++$i) { + $items[] = m::mock(Model::class); + } + $results = new EloquentCollection($items); + + $engine->shouldReceive('paginate')->once(); + $engine->shouldReceive('map')->andReturn($results); + $engine->shouldReceive('getTotalCount')->andReturn(16); + + $model->shouldReceive('newCollection') + ->with(m::type('array')) + ->andReturn($results); + + $builder = new Builder($model, 'zonda'); + $paginated = $builder->paginate(); + + $this->assertSame($results->all(), $paginated->items()); + $this->assertSame(16, $paginated->total()); + $this->assertSame(15, $paginated->perPage()); + $this->assertSame(1, $paginated->currentPage()); + } + + public function testSimplePaginationCorrectlyHandlesPaginatedResults() + { + Paginator::currentPageResolver(function () { + return 1; + }); + Paginator::currentPathResolver(function () { + return 'http://localhost/foo'; + }); + + $model = m::mock(Model::class); + $model->shouldReceive('getPerPage')->andReturn(15); + $model->shouldReceive('searchableUsing')->andReturn($engine = m::mock(Engine::class)); + + // Create collection manually instead of using times() + $items = []; + for ($i = 0; $i < 15; ++$i) { + $items[] = m::mock(Model::class); + } + $results = new EloquentCollection($items); + + $engine->shouldReceive('paginate')->once(); + $engine->shouldReceive('map')->andReturn($results); + $engine->shouldReceive('getTotalCount')->andReturn(16); + + $model->shouldReceive('newCollection') + ->with(m::type('array')) + ->andReturn($results); + + $builder = new Builder($model, 'zonda'); + $paginated = $builder->simplePaginate(); + + $this->assertSame($results->all(), $paginated->items()); + $this->assertTrue($paginated->hasMorePages()); + $this->assertSame(15, $paginated->perPage()); + $this->assertSame(1, $paginated->currentPage()); + } + + public function testPaginateDelegatesToEngineWhenImplementsPaginatesEloquentModels() + { + Paginator::currentPageResolver(fn () => 1); + Paginator::currentPathResolver(fn () => 'http://localhost/foo'); + + $model = m::mock(Model::class); + $model->shouldReceive('getPerPage')->andReturn(15); + + // Create a mock engine that implements PaginatesEloquentModels + $engine = m::mock(Engine::class . ', ' . PaginatesEloquentModels::class); + $model->shouldReceive('searchableUsing')->andReturn($engine); + + $expectedPaginator = new LengthAwarePaginator([], 0, 15, 1); + + // The engine's paginate method should be called directly + $engine->shouldReceive('paginate') + ->once() + ->with(m::type(Builder::class), 15, 1) + ->andReturn($expectedPaginator); + + $builder = new Builder($model, 'test query'); + $result = $builder->paginate(); + + $this->assertInstanceOf(LengthAwarePaginator::class, $result); + } + + public function testSimplePaginateDelegatesToEngineWhenImplementsPaginatesEloquentModels() + { + Paginator::currentPageResolver(fn () => 1); + Paginator::currentPathResolver(fn () => 'http://localhost/foo'); + + $model = m::mock(Model::class); + $model->shouldReceive('getPerPage')->andReturn(15); + + // Create a mock engine that implements PaginatesEloquentModels + $engine = m::mock(Engine::class . ', ' . PaginatesEloquentModels::class); + $model->shouldReceive('searchableUsing')->andReturn($engine); + + $expectedPaginator = new Paginator([], 15, 1); + + // The engine's simplePaginate method should be called directly + $engine->shouldReceive('simplePaginate') + ->once() + ->with(m::type(Builder::class), 15, 1) + ->andReturn($expectedPaginator); + + $builder = new Builder($model, 'test query'); + $result = $builder->simplePaginate(); + + $this->assertInstanceOf(Paginator::class, $result); + } + + public function testPaginateDelegatesToEngineWhenImplementsPaginatesEloquentModelsUsingDatabase() + { + Paginator::currentPageResolver(fn () => 1); + Paginator::currentPathResolver(fn () => 'http://localhost/foo'); + + $model = m::mock(Model::class); + $model->shouldReceive('getPerPage')->andReturn(15); + + // Create a mock engine that implements PaginatesEloquentModelsUsingDatabase + $engine = m::mock(Engine::class . ', ' . PaginatesEloquentModelsUsingDatabase::class); + $model->shouldReceive('searchableUsing')->andReturn($engine); + + $expectedPaginator = new LengthAwarePaginator([], 0, 15, 1); + + // The engine's paginateUsingDatabase method should be called + $engine->shouldReceive('paginateUsingDatabase') + ->once() + ->with(m::type(Builder::class), 15, 'page', 1) + ->andReturn($expectedPaginator); + + $builder = new Builder($model, 'test query'); + $result = $builder->paginate(); + + $this->assertInstanceOf(LengthAwarePaginator::class, $result); + } + + public function testSimplePaginateDelegatesToEngineWhenImplementsPaginatesEloquentModelsUsingDatabase() + { + Paginator::currentPageResolver(fn () => 1); + Paginator::currentPathResolver(fn () => 'http://localhost/foo'); + + $model = m::mock(Model::class); + $model->shouldReceive('getPerPage')->andReturn(15); + + // Create a mock engine that implements PaginatesEloquentModelsUsingDatabase + $engine = m::mock(Engine::class . ', ' . PaginatesEloquentModelsUsingDatabase::class); + $model->shouldReceive('searchableUsing')->andReturn($engine); + + $expectedPaginator = new Paginator([], 15, 1); + + // The engine's simplePaginateUsingDatabase method should be called + $engine->shouldReceive('simplePaginateUsingDatabase') + ->once() + ->with(m::type(Builder::class), 15, 'page', 1) + ->andReturn($expectedPaginator); + + $builder = new Builder($model, 'test query'); + $result = $builder->simplePaginate(); + + $this->assertInstanceOf(Paginator::class, $result); + } + + public function testMacroable() + { + Builder::macro('testMacro', function () { + return 'macro result'; + }); + + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + $this->assertSame('macro result', $builder->testMacro()); + } + + public function testApplyAfterRawSearchCallbackInvokesCallback() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + $builder->withRawResults(function ($results) { + $results['modified'] = true; + return $results; + }); + + $result = $builder->applyAfterRawSearchCallback(['hits' => []]); + + $this->assertTrue($result['modified']); + } + + public function testApplyAfterRawSearchCallbackReturnsOriginalWhenNoCallback() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + $original = ['hits' => []]; + $result = $builder->applyAfterRawSearchCallback($original); + + $this->assertSame($original, $result); + } + + public function testSimplePaginateRawCorrectlyHandlesPaginatedResults() + { + Paginator::currentPageResolver(function () { + return 1; + }); + Paginator::currentPathResolver(function () { + return 'http://localhost/foo'; + }); + + $model = m::mock(Model::class); + $model->shouldReceive('getPerPage')->andReturn(15); + $model->shouldReceive('searchableUsing')->andReturn($engine = m::mock(Engine::class)); + + $rawResults = ['hits' => [], 'estimatedTotalHits' => 16]; + + $engine->shouldReceive('paginate')->once()->andReturn($rawResults); + $engine->shouldReceive('getTotalCount')->andReturn(16); + + $builder = new Builder($model, 'zonda'); + $paginated = $builder->simplePaginateRaw(); + + $this->assertSame($rawResults, $paginated->items()); + $this->assertTrue($paginated->hasMorePages()); + $this->assertSame(15, $paginated->perPage()); + $this->assertSame(1, $paginated->currentPage()); + } + + public function testWhereInAcceptsArrayableInterface() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + // Create a Collection (which implements Arrayable) + $collection = new Collection([1, 2, 3]); + + $result = $builder->whereIn('id', $collection); + + $this->assertSame($builder, $result); + $this->assertSame(['id' => [1, 2, 3]], $builder->whereIns); + } + + public function testWhereNotInAcceptsArrayableInterface() + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + // Create a Collection (which implements Arrayable) + $collection = new Collection([4, 5, 6]); + + $result = $builder->whereNotIn('id', $collection); + + $this->assertSame($builder, $result); + $this->assertSame(['id' => [4, 5, 6]], $builder->whereNotIns); + } +} diff --git a/tests/Scout/Unit/ConfigTest.php b/tests/Scout/Unit/ConfigTest.php new file mode 100644 index 000000000..330333765 --- /dev/null +++ b/tests/Scout/Unit/ConfigTest.php @@ -0,0 +1,130 @@ +resetScoutRunner(); + + parent::tearDown(); + } + + public function testCommandConcurrencyConfigIsUsed(): void + { + // Reset scout runner first to ensure clean state + // (may have been set by previous tests in the same process) + $this->resetScoutRunner(); + + // Set a specific concurrency value + $this->app->get(ConfigInterface::class)->set('scout.command_concurrency', 25); + + // Define SCOUT_COMMAND to trigger the command path + if (! defined('SCOUT_COMMAND')) { + define('SCOUT_COMMAND', true); + } + + // Create a model and trigger searchable job dispatch + $model = new SearchableModel(['title' => 'Test', 'body' => 'Content']); + $model->id = 1; + $model->exists = true; + $model->queueMakeSearchable(new Collection([$model])); + + // Use reflection to get the static $scoutRunner property + $reflection = new ReflectionClass(SearchableModel::class); + $property = $reflection->getProperty('scoutRunner'); + $property->setAccessible(true); + $scoutRunner = $property->getValue(); + + $this->assertInstanceOf(WaitConcurrent::class, $scoutRunner); + + // Get the limit from WaitConcurrent + $runnerReflection = new ReflectionClass($scoutRunner); + $limitProperty = $runnerReflection->getProperty('limit'); + $limitProperty->setAccessible(true); + $limit = $limitProperty->getValue($scoutRunner); + + $this->assertSame(25, $limit); + } + + public function testChunkSearchableConfigAffectsImportEvents(): void + { + // Set a small chunk size to verify multiple events are fired + $this->app->get(ConfigInterface::class)->set('scout.chunk.searchable', 2); + + // Create 5 models + for ($i = 1; $i <= 5; ++$i) { + SearchableModel::create(['title' => "Model {$i}", 'body' => 'Content']); + } + + $eventCount = 0; + Event::listen(ModelsImported::class, function () use (&$eventCount): void { + ++$eventCount; + }); + + // Call makeAllSearchable which uses chunk config + SearchableModel::makeAllSearchable(); + + // Wait for jobs to complete + SearchableModel::waitForSearchableJobs(); + + // With 5 models and chunk size of 2, we expect 3 events (2+2+1) + $this->assertSame(3, $eventCount); + } + + public function testChunkUnsearchableConfigAffectsFlushEvents(): void + { + // Set a small chunk size + $this->app->get(ConfigInterface::class)->set('scout.chunk.unsearchable', 2); + + // Create 5 models + for ($i = 1; $i <= 5; ++$i) { + SearchableModel::create(['title' => "Model {$i}", 'body' => 'Content']); + } + + $eventCount = 0; + Event::listen(ModelsFlushed::class, function () use (&$eventCount): void { + ++$eventCount; + }); + + // Call unsearchable() macro on query which uses chunk config + SearchableModel::query()->unsearchable(); + + // Wait for jobs to complete + SearchableModel::waitForSearchableJobs(); + + // With 5 models and chunk size of 2, we expect 3 events (2+2+1) + $this->assertSame(3, $eventCount); + } + + /** + * Reset the static scout runner property. + */ + private function resetScoutRunner(): void + { + $reflection = new ReflectionClass(SearchableModel::class); + $property = $reflection->getProperty('scoutRunner'); + $property->setAccessible(true); + $property->setValue(null, null); + } +} diff --git a/tests/Scout/Unit/Console/DeleteAllIndexesCommandTest.php b/tests/Scout/Unit/Console/DeleteAllIndexesCommandTest.php new file mode 100644 index 000000000..9ade5165f --- /dev/null +++ b/tests/Scout/Unit/Console/DeleteAllIndexesCommandTest.php @@ -0,0 +1,86 @@ +shouldReceive('deleteAllIndexes') + ->once() + ->andReturn([]); + + $manager = m::mock(EngineManager::class); + $manager->shouldReceive('engine') + ->once() + ->andReturn($engine); + + $command = m::mock(DeleteAllIndexesCommand::class)->makePartial(); + $command->shouldReceive('info') + ->once() + ->with('All indexes deleted successfully.'); + + $result = $command->handle($manager); + + $this->assertSame(0, $result); + } + + public function testFailsWhenEngineDoesNotSupportDeleteAllIndexes(): void + { + $engine = new CollectionEngine(); + + $manager = m::mock(EngineManager::class); + $manager->shouldReceive('engine') + ->once() + ->andReturn($engine); + $manager->shouldReceive('getDefaultDriver') + ->once() + ->andReturn('collection'); + + $command = m::mock(DeleteAllIndexesCommand::class)->makePartial(); + $command->shouldReceive('error') + ->once() + ->with('The [collection] engine does not support deleting all indexes.'); + + $result = $command->handle($manager); + + $this->assertSame(1, $result); + } + + public function testHandlesExceptionFromEngine(): void + { + $engine = m::mock(MeilisearchEngine::class); + $engine->shouldReceive('deleteAllIndexes') + ->once() + ->andThrow(new Exception('Connection failed')); + + $manager = m::mock(EngineManager::class); + $manager->shouldReceive('engine') + ->once() + ->andReturn($engine); + + $command = m::mock(DeleteAllIndexesCommand::class)->makePartial(); + $command->shouldReceive('error') + ->once() + ->with('Connection failed'); + + $result = $command->handle($manager); + + $this->assertSame(1, $result); + } +} diff --git a/tests/Scout/Unit/Console/ImportCommandTest.php b/tests/Scout/Unit/Console/ImportCommandTest.php new file mode 100644 index 000000000..e73504468 --- /dev/null +++ b/tests/Scout/Unit/Console/ImportCommandTest.php @@ -0,0 +1,72 @@ +makePartial(); + $command->shouldReceive('argument') + ->with('model') + ->andReturn('NonExistentModel'); + + $this->expectException(ScoutException::class); + $this->expectExceptionMessage('Model [NonExistentModel] not found.'); + + $command->handle($events); + } + + public function testResolvesModelClassFromAppModelsNamespace(): void + { + // Use reflection to test the protected method on a partial mock + $command = m::mock(ImportCommand::class)->makePartial(); + + $method = new ReflectionMethod(ImportCommand::class, 'resolveModelClass'); + $method->setAccessible(true); + + // Test with a class that exists - use a real class from the codebase + $result = $method->invoke($command, \Hypervel\Scout\Builder::class); + $this->assertSame(\Hypervel\Scout\Builder::class, $result); + } + + public function testResolvesFullyQualifiedClassName(): void + { + $command = m::mock(ImportCommand::class)->makePartial(); + + $method = new ReflectionMethod(ImportCommand::class, 'resolveModelClass'); + $method->setAccessible(true); + + // Test with fully qualified class name + $result = $method->invoke($command, \Hypervel\Scout\Engine::class); + $this->assertSame(\Hypervel\Scout\Engine::class, $result); + } + + public function testThrowsExceptionForNonExistentClass(): void + { + $command = m::mock(ImportCommand::class)->makePartial(); + + $method = new ReflectionMethod(ImportCommand::class, 'resolveModelClass'); + $method->setAccessible(true); + + $this->expectException(ScoutException::class); + $this->expectExceptionMessage('Model [FakeModelThatDoesNotExist] not found.'); + + $method->invoke($command, 'FakeModelThatDoesNotExist'); + } +} diff --git a/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php b/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php new file mode 100644 index 000000000..f62fef6dc --- /dev/null +++ b/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php @@ -0,0 +1,183 @@ +shouldReceive('engine') + ->with('collection') + ->once() + ->andReturn($engine); + + $config = m::mock(ConfigInterface::class); + $config->shouldReceive('get') + ->with('scout.driver') + ->andReturn('collection'); + + $command = m::mock(SyncIndexSettingsCommand::class)->makePartial(); + $command->shouldReceive('option') + ->with('driver') + ->andReturn(null); + $command->shouldReceive('error') + ->once() + ->with('The "collection" engine does not support updating index settings.'); + + $result = $command->handle($manager, $config); + + $this->assertSame(1, $result); + } + + public function testSucceedsWithInfoMessageWhenNoIndexSettingsConfigured(): void + { + $engine = m::mock(Engine::class . ', ' . UpdatesIndexSettings::class); + + $manager = m::mock(EngineManager::class); + $manager->shouldReceive('engine') + ->with('meilisearch') + ->once() + ->andReturn($engine); + + $config = m::mock(ConfigInterface::class); + $config->shouldReceive('get') + ->with('scout.driver') + ->andReturn('meilisearch'); + $config->shouldReceive('get') + ->with('scout.meilisearch.index-settings', []) + ->andReturn([]); + + $command = m::mock(SyncIndexSettingsCommand::class)->makePartial(); + $command->shouldReceive('option') + ->with('driver') + ->andReturn(null); + $command->shouldReceive('info') + ->once() + ->with('No index settings found for the "meilisearch" engine.'); + + $result = $command->handle($manager, $config); + + $this->assertSame(0, $result); + } + + public function testSyncsIndexSettingsSuccessfully(): void + { + $engine = m::mock(Engine::class . ', ' . UpdatesIndexSettings::class); + $engine->shouldReceive('updateIndexSettings') + ->once() + ->with('test_posts', ['filterableAttributes' => ['status']]); + + $manager = m::mock(EngineManager::class); + $manager->shouldReceive('engine') + ->with('meilisearch') + ->once() + ->andReturn($engine); + + $config = m::mock(ConfigInterface::class); + $config->shouldReceive('get') + ->with('scout.driver') + ->andReturn('meilisearch'); + $config->shouldReceive('get') + ->with('scout.meilisearch.index-settings', []) + ->andReturn([ + 'test_posts' => ['filterableAttributes' => ['status']], + ]); + $config->shouldReceive('get') + ->with('scout.prefix', '') + ->andReturn(''); + + $command = m::mock(SyncIndexSettingsCommand::class)->makePartial(); + $command->shouldReceive('option') + ->with('driver') + ->andReturn(null); + $command->shouldReceive('info') + ->once() + ->with('Settings for the [test_posts] index synced successfully.'); + + $result = $command->handle($manager, $config); + + $this->assertSame(0, $result); + } + + public function testUsesDriverOptionWhenProvided(): void + { + $engine = m::mock(Engine::class . ', ' . UpdatesIndexSettings::class); + + $manager = m::mock(EngineManager::class); + $manager->shouldReceive('engine') + ->with('typesense') + ->once() + ->andReturn($engine); + + $config = m::mock(ConfigInterface::class); + // Note: scout.driver should NOT be called when driver option is provided + $config->shouldReceive('get') + ->with('scout.typesense.index-settings', []) + ->andReturn([]); + + $command = m::mock(SyncIndexSettingsCommand::class)->makePartial(); + $command->shouldReceive('option') + ->with('driver') + ->andReturn('typesense'); + $command->shouldReceive('info') + ->once() + ->with('No index settings found for the "typesense" engine.'); + + $result = $command->handle($manager, $config); + + $this->assertSame(0, $result); + } + + public function testIndexNameResolutionPrependsPrefix(): void + { + $command = m::mock(SyncIndexSettingsCommand::class)->makePartial(); + + $method = new ReflectionMethod(SyncIndexSettingsCommand::class, 'indexName'); + $method->setAccessible(true); + + $config = m::mock(ConfigInterface::class); + $config->shouldReceive('get') + ->with('scout.prefix', '') + ->andReturn('prod_'); + + // Test that prefix is prepended when not already present + $result = $method->invoke($command, 'posts', $config); + $this->assertSame('prod_posts', $result); + } + + public function testIndexNameResolutionDoesNotDuplicatePrefix(): void + { + $command = m::mock(SyncIndexSettingsCommand::class)->makePartial(); + + $method = new ReflectionMethod(SyncIndexSettingsCommand::class, 'indexName'); + $method->setAccessible(true); + + $config = m::mock(ConfigInterface::class); + $config->shouldReceive('get') + ->with('scout.prefix', '') + ->andReturn('prod_'); + + // Test that prefix is NOT duplicated when already present + $result = $method->invoke($command, 'prod_posts', $config); + $this->assertSame('prod_posts', $result); + } +} diff --git a/tests/Scout/Unit/EngineManagerTest.php b/tests/Scout/Unit/EngineManagerTest.php new file mode 100644 index 000000000..d3845bdd9 --- /dev/null +++ b/tests/Scout/Unit/EngineManagerTest.php @@ -0,0 +1,317 @@ +forgetEngines(); + } + + public function testResolveNullEngine() + { + $container = $this->createMockContainer(['driver' => 'null']); + + $manager = new EngineManager($container); + $engine = $manager->engine('null'); + + $this->assertInstanceOf(NullEngine::class, $engine); + } + + public function testResolveCollectionEngine() + { + $container = $this->createMockContainer(['driver' => 'collection']); + + $manager = new EngineManager($container); + $engine = $manager->engine('collection'); + + $this->assertInstanceOf(CollectionEngine::class, $engine); + } + + public function testResolveMeilisearchEngine() + { + $container = $this->createMockContainer([ + 'driver' => 'meilisearch', + 'soft_delete' => false, + ]); + + $meilisearchClient = m::mock(MeilisearchClient::class); + $container->shouldReceive('get') + ->with(MeilisearchClient::class) + ->andReturn($meilisearchClient); + + $manager = new EngineManager($container); + $engine = $manager->engine('meilisearch'); + + $this->assertInstanceOf(MeilisearchEngine::class, $engine); + } + + public function testResolveMeilisearchEngineWithSoftDelete() + { + $container = $this->createMockContainer([ + 'driver' => 'meilisearch', + 'soft_delete' => true, + ]); + + $meilisearchClient = m::mock(MeilisearchClient::class); + $container->shouldReceive('get') + ->with(MeilisearchClient::class) + ->andReturn($meilisearchClient); + + $manager = new EngineManager($container); + $engine = $manager->engine('meilisearch'); + + $this->assertInstanceOf(MeilisearchEngine::class, $engine); + } + + public function testResolveDatabaseEngine() + { + $container = $this->createMockContainer(['driver' => 'database']); + + $manager = new EngineManager($container); + $engine = $manager->engine('database'); + + $this->assertInstanceOf(DatabaseEngine::class, $engine); + } + + public function testResolveTypesenseEngine() + { + $container = $this->createMockContainerWithTypesense([ + 'driver' => 'typesense', + 'soft_delete' => false, + ]); + + $typesenseClient = m::mock(TypesenseClient::class); + $container->shouldReceive('get') + ->with(TypesenseClient::class) + ->andReturn($typesenseClient); + + $manager = new EngineManager($container); + $engine = $manager->engine('typesense'); + + $this->assertInstanceOf(TypesenseEngine::class, $engine); + } + + public function testEngineUsesDefaultDriver() + { + $container = $this->createMockContainer(['driver' => 'collection']); + + $manager = new EngineManager($container); + $engine = $manager->engine(); // No name specified + + $this->assertInstanceOf(CollectionEngine::class, $engine); + } + + public function testEngineDefaultsToNullWhenNoDriverConfigured() + { + $container = $this->createMockContainer(['driver' => null]); + + $manager = new EngineManager($container); + $engine = $manager->engine(); + + $this->assertInstanceOf(NullEngine::class, $engine); + } + + public function testEngineCachesInstances() + { + $container = $this->createMockContainer(['driver' => 'collection']); + + $manager = new EngineManager($container); + + $engine1 = $manager->engine('collection'); + $engine2 = $manager->engine('collection'); + + $this->assertSame($engine1, $engine2); + } + + public function testForgetEnginesClearsCache() + { + $container = $this->createMockContainer(['driver' => 'collection']); + + $manager = new EngineManager($container); + + $engine1 = $manager->engine('collection'); + $manager->forgetEngines(); + $engine2 = $manager->engine('collection'); + + $this->assertNotSame($engine1, $engine2); + } + + public function testForgetEngineClearsSpecificEngine() + { + $container = $this->createMockContainer(['driver' => 'collection']); + + $manager = new EngineManager($container); + + $collection1 = $manager->engine('collection'); + $null1 = $manager->engine('null'); + + $manager->forgetEngine('collection'); + + $collection2 = $manager->engine('collection'); + $null2 = $manager->engine('null'); + + $this->assertNotSame($collection1, $collection2); + $this->assertSame($null1, $null2); + } + + public function testExtendRegisterCustomDriver() + { + $container = $this->createMockContainer(['driver' => 'custom']); + + $customEngine = m::mock(Engine::class); + + $manager = new EngineManager($container); + $manager->extend('custom', function ($container) use ($customEngine) { + return $customEngine; + }); + + $engine = $manager->engine('custom'); + + $this->assertSame($customEngine, $engine); + } + + public function testExtendCustomDriverReceivesContainer() + { + $container = $this->createMockContainer(['driver' => 'custom']); + + $receivedContainer = null; + $customEngine = m::mock(Engine::class); + + $manager = new EngineManager($container); + $manager->extend('custom', function ($passedContainer) use (&$receivedContainer, $customEngine) { + $receivedContainer = $passedContainer; + return $customEngine; + }); + + $manager->engine('custom'); + + $this->assertSame($container, $receivedContainer); + } + + public function testCustomDriverOverridesBuiltIn() + { + $container = $this->createMockContainer(['driver' => 'collection']); + + $customEngine = m::mock(Engine::class); + + $manager = new EngineManager($container); + $manager->extend('collection', function () use ($customEngine) { + return $customEngine; + }); + + $engine = $manager->engine('collection'); + + $this->assertSame($customEngine, $engine); + } + + public function testThrowsExceptionForUnsupportedDriver() + { + $container = $this->createMockContainer(['driver' => 'unsupported']); + + $manager = new EngineManager($container); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Driver [unsupported] is not supported.'); + + $manager->engine('unsupported'); + } + + public function testGetDefaultDriverReturnsConfiguredDriver() + { + $container = $this->createMockContainer(['driver' => 'meilisearch']); + + $manager = new EngineManager($container); + + $this->assertSame('meilisearch', $manager->getDefaultDriver()); + } + + public function testGetDefaultDriverReturnsNullWhenNotConfigured() + { + $container = $this->createMockContainer(['driver' => null]); + + $manager = new EngineManager($container); + + $this->assertSame('null', $manager->getDefaultDriver()); + } + + public function testStaticCacheIsSharedAcrossInstances() + { + $container = $this->createMockContainer(['driver' => 'collection']); + + $manager1 = new EngineManager($container); + $engine1 = $manager1->engine('collection'); + + $manager2 = new EngineManager($container); + $engine2 = $manager2->engine('collection'); + + // Static cache means same instance + $this->assertSame($engine1, $engine2); + } + + protected function createMockContainer(array $config): m\MockInterface&ContainerInterface + { + $container = m::mock(ContainerInterface::class); + + $configService = m::mock(ConfigInterface::class); + $configService->shouldReceive('get') + ->with('scout.driver', m::any()) + ->andReturn($config['driver'] ?? null); + $configService->shouldReceive('get') + ->with('scout.soft_delete', m::any()) + ->andReturn($config['soft_delete'] ?? false); + + $container->shouldReceive('get') + ->with(ConfigInterface::class) + ->andReturn($configService); + + return $container; + } + + protected function createMockContainerWithTypesense(array $config): m\MockInterface&ContainerInterface + { + $container = m::mock(ContainerInterface::class); + + $configService = m::mock(ConfigInterface::class); + $configService->shouldReceive('get') + ->with('scout.driver', m::any()) + ->andReturn($config['driver'] ?? null); + $configService->shouldReceive('get') + ->with('scout.soft_delete', m::any()) + ->andReturn($config['soft_delete'] ?? false); + $configService->shouldReceive('get') + ->with('scout.typesense.max_total_results', m::any()) + ->andReturn($config['max_total_results'] ?? 1000); + + $container->shouldReceive('get') + ->with(ConfigInterface::class) + ->andReturn($configService); + + return $container; + } +} diff --git a/tests/Scout/Unit/Engines/MeilisearchEngineTest.php b/tests/Scout/Unit/Engines/MeilisearchEngineTest.php new file mode 100644 index 000000000..ead5d268d --- /dev/null +++ b/tests/Scout/Unit/Engines/MeilisearchEngineTest.php @@ -0,0 +1,571 @@ +shouldReceive('index') + ->with('test_index') + ->once() + ->andReturn($index); + + $index->shouldReceive('addDocuments') + ->once() + ->with([ + ['id' => 1, 'title' => 'Test'], + ], 'id'); + + $engine = new MeilisearchEngine($client); + + $model = $this->createSearchableModelMock(); + $model->shouldReceive('indexableAs')->andReturn('test_index'); + $model->shouldReceive('toSearchableArray')->andReturn(['id' => 1, 'title' => 'Test']); + $model->shouldReceive('scoutMetadata')->andReturn([]); + $model->shouldReceive('getScoutKeyName')->andReturn('id'); + $model->shouldReceive('getScoutKey')->andReturn(1); + + $engine->update(new EloquentCollection([$model])); + } + + public function testUpdateEmptyCollectionDoesNothing() + { + $client = m::mock(Client::class); + $client->shouldNotReceive('index'); + + $engine = new MeilisearchEngine($client); + $engine->update(new EloquentCollection()); + + $this->assertTrue(true); + } + + public function testUpdateWithSoftDeletesAddsSoftDeleteMetadata() + { + $client = m::mock(Client::class); + $index = m::mock(Indexes::class); + + $client->shouldReceive('index') + ->with('test_index') + ->once() + ->andReturn($index); + + $index->shouldReceive('addDocuments') + ->once() + ->with(m::on(function ($documents) { + return isset($documents[0]['__soft_deleted']); + }), 'id'); + + $engine = new MeilisearchEngine($client, softDelete: true); + + $model = $this->createSoftDeleteSearchableModelMock(); + $model->shouldReceive('indexableAs')->andReturn('test_index'); + $model->shouldReceive('toSearchableArray')->andReturn(['id' => 1, 'title' => 'Test']); + $model->shouldReceive('scoutMetadata')->andReturn(['__soft_deleted' => 0]); + $model->shouldReceive('getScoutKeyName')->andReturn('id'); + $model->shouldReceive('getScoutKey')->andReturn(1); + $model->shouldReceive('pushSoftDeleteMetadata')->once()->andReturnSelf(); + + $engine->update(new EloquentCollection([$model])); + } + + public function testDeleteRemovesDocumentsFromIndex() + { + $client = m::mock(Client::class); + $index = m::mock(Indexes::class); + + $client->shouldReceive('index') + ->with('test_index') + ->once() + ->andReturn($index); + + $index->shouldReceive('deleteDocuments') + ->once() + ->with([1, 2]); + + $engine = new MeilisearchEngine($client); + + $model1 = $this->createSearchableModelMock(); + $model1->shouldReceive('indexableAs')->andReturn('test_index'); + $model1->shouldReceive('getScoutKey')->andReturn(1); + + $model2 = $this->createSearchableModelMock(); + $model2->shouldReceive('getScoutKey')->andReturn(2); + + $engine->delete(new EloquentCollection([$model1, $model2])); + } + + public function testDeleteEmptyCollectionDoesNothing() + { + $client = m::mock(Client::class); + $client->shouldNotReceive('index'); + + $engine = new MeilisearchEngine($client); + $engine->delete(new EloquentCollection()); + + $this->assertTrue(true); + } + + public function testSearchPerformsSearchOnMeilisearch() + { + $client = m::mock(Client::class); + $index = m::mock(Indexes::class); + + $client->shouldReceive('index') + ->with('test_index') + ->once() + ->andReturn($index); + + $index->shouldReceive('rawSearch') + ->once() + ->with('test query', m::any()) + ->andReturn(['hits' => [], 'totalHits' => 0]); + + $engine = new MeilisearchEngine($client); + + $model = m::mock(MeilisearchTestSearchableModel::class); + $model->shouldReceive('searchableAs')->andReturn('test_index'); + $model->shouldReceive('getScoutKeyName')->andReturn('id'); + + $builder = new Builder($model, 'test query'); + + $result = $engine->search($builder); + + $this->assertEquals(['hits' => [], 'totalHits' => 0], $result); + } + + public function testSearchWithFilters() + { + $client = m::mock(Client::class); + $index = m::mock(Indexes::class); + + $client->shouldReceive('index') + ->with('test_index') + ->once() + ->andReturn($index); + + $index->shouldReceive('rawSearch') + ->once() + ->with('query', m::on(function ($params) { + return str_contains($params['filter'], 'status="active"'); + })) + ->andReturn(['hits' => [], 'totalHits' => 0]); + + $engine = new MeilisearchEngine($client); + + $model = m::mock(MeilisearchTestSearchableModel::class); + $model->shouldReceive('searchableAs')->andReturn('test_index'); + $model->shouldReceive('getScoutKeyName')->andReturn('id'); + + $builder = new Builder($model, 'query'); + $builder->where('status', 'active'); + + $engine->search($builder); + } + + public function testPaginatePerformsPaginatedSearch() + { + $client = m::mock(Client::class); + $index = m::mock(Indexes::class); + + $client->shouldReceive('index') + ->with('test_index') + ->once() + ->andReturn($index); + + $index->shouldReceive('rawSearch') + ->once() + ->with('query', m::on(function ($params) { + return $params['hitsPerPage'] === 15 && $params['page'] === 2; + })) + ->andReturn(['hits' => [], 'totalHits' => 0]); + + $engine = new MeilisearchEngine($client); + + $model = m::mock(MeilisearchTestSearchableModel::class); + $model->shouldReceive('searchableAs')->andReturn('test_index'); + $model->shouldReceive('getScoutKeyName')->andReturn('id'); + + $builder = new Builder($model, 'query'); + + $engine->paginate($builder, 15, 2); + } + + public function testMapIdsReturnsEmptyCollectionIfNoHits() + { + $client = m::mock(Client::class); + $engine = new MeilisearchEngine($client); + + $results = $engine->mapIdsFrom([ + 'totalHits' => 0, + 'hits' => [], + ], 'id'); + + $this->assertCount(0, $results); + } + + public function testMapIdsReturnsCorrectPrimaryKeys() + { + $client = m::mock(Client::class); + $engine = new MeilisearchEngine($client); + + $results = $engine->mapIdsFrom([ + 'totalHits' => 4, + 'hits' => [ + ['id' => 1, 'title' => 'Test 1'], + ['id' => 2, 'title' => 'Test 2'], + ['id' => 3, 'title' => 'Test 3'], + ['id' => 4, 'title' => 'Test 4'], + ], + ], 'id'); + + $this->assertEquals([1, 2, 3, 4], $results->all()); + } + + public function testMapCorrectlyMapsResultsToModels() + { + $client = m::mock(Client::class); + $engine = new MeilisearchEngine($client); + + // Create a mock searchable model that tracks scout metadata + $searchableModel = m::mock(Model::class . ', ' . SearchableInterface::class); + $searchableModel->shouldReceive('getScoutKey')->andReturn(1); + $searchableModel->shouldReceive('withScoutMetadata') + ->with('_rankingScore', 0.86) + ->once() + ->andReturnSelf(); + + $model = m::mock(Model::class . ', ' . SearchableInterface::class); + $model->shouldReceive('getScoutKeyName')->andReturn('id'); + $model->shouldReceive('getScoutModelsByIds')->andReturn(new EloquentCollection([$searchableModel])); + + $builder = m::mock(Builder::class); + + $results = $engine->map($builder, [ + 'totalHits' => 1, + 'hits' => [ + ['id' => 1, '_rankingScore' => 0.86], + ], + ], $model); + + $this->assertCount(1, $results); + } + + public function testMapReturnsEmptyCollectionWhenNoHits() + { + $client = m::mock(Client::class); + $engine = new MeilisearchEngine($client); + + $model = m::mock(MeilisearchTestSearchableModel::class); + $model->shouldReceive('newCollection')->andReturn(new EloquentCollection()); + + $builder = m::mock(Builder::class); + + $results = $engine->map($builder, ['hits' => []], $model); + + $this->assertCount(0, $results); + } + + public function testMapRespectsOrder() + { + $client = m::mock(Client::class); + $engine = new MeilisearchEngine($client); + + // Create mock models + $mockModels = []; + foreach ([1, 2, 3, 4] as $id) { + $mock = m::mock(Model::class . ', ' . SearchableInterface::class); + $mock->shouldReceive('getScoutKey')->andReturn($id); + $mockModels[] = $mock; + } + + $models = new EloquentCollection($mockModels); + + $model = m::mock(Model::class . ', ' . SearchableInterface::class); + $model->shouldReceive('getScoutKeyName')->andReturn('id'); + $model->shouldReceive('getScoutModelsByIds')->andReturn($models); + + $builder = m::mock(Builder::class); + + $results = $engine->map($builder, [ + 'totalHits' => 4, + 'hits' => [ + ['id' => 1], + ['id' => 2], + ['id' => 4], + ['id' => 3], + ], + ], $model); + + $this->assertCount(4, $results); + // Check order is respected: 1, 2, 4, 3 + $resultIds = $results->map(fn ($m) => $m->getScoutKey())->all(); + $this->assertEquals([1, 2, 4, 3], $resultIds); + } + + public function testLazyMapReturnsEmptyCollectionWhenNoHits() + { + $client = m::mock(Client::class); + $engine = new MeilisearchEngine($client); + + $model = m::mock(MeilisearchTestSearchableModel::class); + $model->shouldReceive('newCollection')->andReturn(new EloquentCollection()); + + $builder = m::mock(Builder::class); + + $results = $engine->lazyMap($builder, ['hits' => []], $model); + + $this->assertInstanceOf(LazyCollection::class, $results); + $this->assertCount(0, $results); + } + + public function testGetTotalCountReturnsTotalHits() + { + $client = m::mock(Client::class); + $engine = new MeilisearchEngine($client); + + $this->assertSame(3, $engine->getTotalCount(['totalHits' => 3])); + } + + public function testGetTotalCountReturnsEstimatedTotalHits() + { + $client = m::mock(Client::class); + $engine = new MeilisearchEngine($client); + + $this->assertSame(5, $engine->getTotalCount(['estimatedTotalHits' => 5])); + } + + public function testGetTotalCountReturnsZeroWhenNoCountAvailable() + { + $client = m::mock(Client::class); + $engine = new MeilisearchEngine($client); + + $this->assertSame(0, $engine->getTotalCount([])); + } + + public function testFlushDeletesAllDocuments() + { + $client = m::mock(Client::class); + $index = m::mock(Indexes::class); + + $client->shouldReceive('index') + ->with('test_index') + ->once() + ->andReturn($index); + + $index->shouldReceive('deleteAllDocuments')->once(); + + $engine = new MeilisearchEngine($client); + + $model = m::mock(MeilisearchTestSearchableModel::class); + $model->shouldReceive('indexableAs')->andReturn('test_index'); + + $engine->flush($model); + } + + public function testCreateIndexCreatesNewIndex() + { + $client = m::mock(Client::class); + + $client->shouldReceive('getIndex') + ->with('test_index') + ->once() + ->andThrow(new \Meilisearch\Exceptions\ApiException( + new \GuzzleHttp\Psr7\Response(404), + ['message' => 'Index not found'] + )); + + $taskInfo = ['taskUid' => 1, 'indexUid' => 'test_index', 'status' => 'enqueued']; + $client->shouldReceive('createIndex') + ->with('test_index', ['primaryKey' => 'id']) + ->once() + ->andReturn($taskInfo); + + $engine = new MeilisearchEngine($client); + + $result = $engine->createIndex('test_index', ['primaryKey' => 'id']); + + $this->assertSame($taskInfo, $result); + } + + public function testCreateIndexReturnsExistingIndex() + { + $client = m::mock(Client::class); + $index = m::mock(Indexes::class); + + $index->shouldReceive('getUid')->andReturn('test_index'); + + $client->shouldReceive('getIndex') + ->with('test_index') + ->once() + ->andReturn($index); + + $client->shouldNotReceive('createIndex'); + + $engine = new MeilisearchEngine($client); + + $result = $engine->createIndex('test_index'); + + $this->assertSame($index, $result); + } + + public function testDeleteIndexDeletesIndex() + { + $client = m::mock(Client::class); + + $client->shouldReceive('deleteIndex') + ->with('test_index') + ->once() + ->andReturn(['taskUid' => 1]); + + $engine = new MeilisearchEngine($client); + + $result = $engine->deleteIndex('test_index'); + + $this->assertEquals(['taskUid' => 1], $result); + } + + public function testUpdateIndexSettingsWithEmbedders() + { + $client = m::mock(Client::class); + $index = m::mock(Indexes::class); + + $client->shouldReceive('index') + ->with('test_index') + ->once() + ->andReturn($index); + + $index->shouldReceive('updateSettings') + ->with(['searchableAttributes' => ['title']]) + ->once(); + + $index->shouldReceive('updateEmbedders') + ->with(['default' => ['source' => 'openAi']]) + ->once(); + + $engine = new MeilisearchEngine($client); + $engine->updateIndexSettings('test_index', [ + 'searchableAttributes' => ['title'], + 'embedders' => ['default' => ['source' => 'openAi']], + ]); + + $this->assertTrue(true); + } + + public function testUpdateIndexSettingsWithoutEmbedders() + { + $client = m::mock(Client::class); + $index = m::mock(Indexes::class); + + $client->shouldReceive('index') + ->with('test_index') + ->once() + ->andReturn($index); + + $index->shouldReceive('updateSettings') + ->with(['searchableAttributes' => ['title', 'body']]) + ->once(); + + $index->shouldNotReceive('updateEmbedders'); + + $engine = new MeilisearchEngine($client); + $engine->updateIndexSettings('test_index', [ + 'searchableAttributes' => ['title', 'body'], + ]); + + $this->assertTrue(true); + } + + public function testConfigureSoftDeleteFilterAddsFilterableAttribute() + { + $client = m::mock(Client::class); + $engine = new MeilisearchEngine($client); + + $settings = $engine->configureSoftDeleteFilter([ + 'filterableAttributes' => ['status'], + ]); + + $this->assertContains('__soft_deleted', $settings['filterableAttributes']); + $this->assertContains('status', $settings['filterableAttributes']); + } + + public function testEngineForwardsCallsToMeilisearchClient() + { + $client = m::mock(Client::class); + $client->shouldReceive('health') + ->once() + ->andReturn(['status' => 'available']); + + $engine = new MeilisearchEngine($client); + + $result = $engine->health(); + + $this->assertEquals(['status' => 'available'], $result); + } + + public function testGetMeilisearchClientReturnsClient() + { + $client = m::mock(Client::class); + $engine = new MeilisearchEngine($client); + + $this->assertSame($client, $engine->getMeilisearchClient()); + } + + protected function createSearchableModelMock(): m\MockInterface + { + return m::mock(Model::class . ', ' . SearchableInterface::class); + } + + protected function createSoftDeleteSearchableModelMock(): m\MockInterface + { + // Must mock a class that uses SoftDeletes for usesSoftDelete() to return true + return m::mock(MeilisearchTestSoftDeleteModel::class . ', ' . SearchableInterface::class); + } +} + +/** + * Test model for MeilisearchEngine tests. + */ +class MeilisearchTestSearchableModel extends Model implements SearchableInterface +{ + use Searchable; + + protected array $guarded = []; + + public bool $timestamps = false; +} + +/** + * Test model with soft deletes for MeilisearchEngine tests. + */ +class MeilisearchTestSoftDeleteModel extends Model implements SearchableInterface +{ + use Searchable; + use SoftDeletes; + + protected array $guarded = []; + + public bool $timestamps = false; +} diff --git a/tests/Scout/Unit/Engines/NullEngineTest.php b/tests/Scout/Unit/Engines/NullEngineTest.php new file mode 100644 index 000000000..1b0887299 --- /dev/null +++ b/tests/Scout/Unit/Engines/NullEngineTest.php @@ -0,0 +1,141 @@ +update($models); + $this->assertTrue(true); + } + + public function testDeleteDoesNothing() + { + $engine = new NullEngine(); + $models = new EloquentCollection([m::mock(Model::class)]); + + // Should not throw any exception + $engine->delete($models); + $this->assertTrue(true); + } + + public function testSearchReturnsEmptyArray() + { + $engine = new NullEngine(); + $builder = m::mock(Builder::class); + + $result = $engine->search($builder); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testPaginateReturnsEmptyArray() + { + $engine = new NullEngine(); + $builder = m::mock(Builder::class); + + $result = $engine->paginate($builder, 15, 1); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testMapIdsReturnsEmptyCollection() + { + $engine = new NullEngine(); + + $result = $engine->mapIds([]); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertTrue($result->isEmpty()); + } + + public function testMapReturnsEmptyEloquentCollection() + { + $engine = new NullEngine(); + $builder = m::mock(Builder::class); + $model = m::mock(Model::class); + + $result = $engine->map($builder, [], $model); + + $this->assertInstanceOf(EloquentCollection::class, $result); + $this->assertTrue($result->isEmpty()); + } + + public function testLazyMapReturnsEmptyLazyCollection() + { + $engine = new NullEngine(); + $builder = m::mock(Builder::class); + $model = m::mock(Model::class); + + $result = $engine->lazyMap($builder, [], $model); + + $this->assertInstanceOf(LazyCollection::class, $result); + $this->assertTrue($result->isEmpty()); + } + + public function testGetTotalCountReturnsZeroForEmptyResults() + { + $engine = new NullEngine(); + + $this->assertSame(0, $engine->getTotalCount([])); + } + + public function testGetTotalCountReturnsCountForCountableResults() + { + $engine = new NullEngine(); + + $this->assertSame(3, $engine->getTotalCount([1, 2, 3])); + } + + public function testFlushDoesNothing() + { + $engine = new NullEngine(); + $model = m::mock(Model::class); + + // Should not throw any exception + $engine->flush($model); + $this->assertTrue(true); + } + + public function testCreateIndexReturnsEmptyArray() + { + $engine = new NullEngine(); + + $result = $engine->createIndex('test-index'); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testDeleteIndexReturnsEmptyArray() + { + $engine = new NullEngine(); + + $result = $engine->deleteIndex('test-index'); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } +} diff --git a/tests/Scout/Unit/Engines/TypesenseEngineTest.php b/tests/Scout/Unit/Engines/TypesenseEngineTest.php new file mode 100644 index 000000000..a7dd246cf --- /dev/null +++ b/tests/Scout/Unit/Engines/TypesenseEngineTest.php @@ -0,0 +1,577 @@ +shouldAllowMockingProtectedMethods() + ->makePartial(); + } + + protected function createSearchableModelMock(): MockInterface + { + return Mockery::mock(Model::class . ', ' . SearchableInterface::class); + } + + protected function invokeMethod(object $object, string $methodName, array $parameters = []): mixed + { + $method = new ReflectionMethod($object, $methodName); + + return $method->invoke($object, ...$parameters); + } + + public function testFiltersMethod(): void + { + $engine = $this->createEngine(); + + $builder = Mockery::mock(Builder::class); + $builder->wheres = [ + 'status' => 'active', + 'age' => 25, + ]; + $builder->whereIns = [ + 'category' => ['electronics', 'books'], + ]; + $builder->whereNotIns = [ + 'brand' => ['apple', 'samsung'], + ]; + + $result = $this->invokeMethod($engine, 'filters', [$builder]); + + $this->assertStringContainsString('status:=active', $result); + $this->assertStringContainsString('age:=25', $result); + $this->assertStringContainsString('category:=[electronics, books]', $result); + $this->assertStringContainsString('brand:!=[apple, samsung]', $result); + } + + public function testParseFilterValueMethod(): void + { + $engine = $this->createEngine(); + + $this->assertEquals('true', $this->invokeMethod($engine, 'parseFilterValue', [true])); + $this->assertEquals('false', $this->invokeMethod($engine, 'parseFilterValue', [false])); + $this->assertEquals(25, $this->invokeMethod($engine, 'parseFilterValue', [25])); + $this->assertEquals(3.14, $this->invokeMethod($engine, 'parseFilterValue', [3.14])); + $this->assertEquals('test', $this->invokeMethod($engine, 'parseFilterValue', ['test'])); + } + + public function testParseWhereFilterMethod(): void + { + $engine = $this->createEngine(); + + $this->assertEquals('status:=active', $this->invokeMethod($engine, 'parseWhereFilter', ['active', 'status'])); + $this->assertEquals('age:=25', $this->invokeMethod($engine, 'parseWhereFilter', [25, 'age'])); + } + + public function testParseWhereInFilterMethod(): void + { + $engine = $this->createEngine(); + + $this->assertEquals( + 'category:=[electronics, books]', + $this->invokeMethod($engine, 'parseWhereInFilter', [['electronics', 'books'], 'category']) + ); + } + + public function testParseWhereNotInFilterMethod(): void + { + $engine = $this->createEngine(); + + $this->assertEquals( + 'brand:!=[apple, samsung]', + $this->invokeMethod($engine, 'parseWhereNotInFilter', [['apple', 'samsung'], 'brand']) + ); + } + + public function testParseOrderByMethod(): void + { + $engine = $this->createEngine(); + + $orders = [ + ['column' => 'name', 'direction' => 'asc'], + ['column' => 'created_at', 'direction' => 'desc'], + ]; + + $result = $this->invokeMethod($engine, 'parseOrderBy', [$orders]); + + $this->assertEquals('name:asc,created_at:desc', $result); + } + + public function testMapIdsMethod(): void + { + $engine = $this->createEngine(); + + $results = [ + 'hits' => [ + ['document' => ['id' => 1]], + ['document' => ['id' => 2]], + ['document' => ['id' => 3]], + ], + ]; + + $ids = $engine->mapIds($results); + + $this->assertEquals([1, 2, 3], $ids->all()); + } + + public function testMapIdsReturnsEmptyCollectionForNoHits(): void + { + $engine = $this->createEngine(); + + $results = ['hits' => []]; + + $ids = $engine->mapIds($results); + + $this->assertTrue($ids->isEmpty()); + } + + public function testGetTotalCountMethod(): void + { + $engine = $this->createEngine(); + + $resultsWithFound = ['found' => 5]; + $resultsWithoutFound = ['hits' => []]; + + $this->assertEquals(5, $engine->getTotalCount($resultsWithFound)); + $this->assertEquals(0, $engine->getTotalCount($resultsWithoutFound)); + } + + public function testCreateIndexThrowsNotSupportedException(): void + { + $engine = $this->createEngine(); + + $this->expectException(NotSupportedException::class); + $this->expectExceptionMessage('Typesense indexes are created automatically upon adding objects.'); + + $engine->createIndex('test_index'); + } + + public function testUpdateWithEmptyCollectionDoesNothing(): void + { + $client = Mockery::mock(TypesenseClient::class); + $client->shouldNotReceive('getCollections'); + + $engine = $this->createEngine($client); + + $engine->update(new EloquentCollection([])); + + $this->assertTrue(true); // No exception means success + } + + public function testDeleteRemovesDocumentsFromIndex(): void + { + $model = $this->createSearchableModelMock(); + $model->shouldReceive('getScoutKey')->andReturn(123); + + // Mock the Document object that's returned by array access on Documents + $document = Mockery::mock(Document::class); + $document->shouldReceive('retrieve')->once()->andReturn([]); + $document->shouldReceive('delete')->once()->andReturn([]); + + // Documents already implements ArrayAccess + $documents = Mockery::mock(Documents::class); + $documents->shouldReceive('offsetGet') + ->with('123') + ->andReturn($document); + + $collection = Mockery::mock(TypesenseCollection::class); + $collection->shouldReceive('getDocuments')->andReturn($documents); + + $engine = $this->createPartialEngine(); + $engine->shouldReceive('getOrCreateCollectionFromModel') + ->once() + ->with($model, null, false) // Verify indexOperation=false to prevent collection creation + ->andReturn($collection); + + $engine->delete(new EloquentCollection([$model])); + } + + public function testDeleteWithEmptyCollectionDoesNothing(): void + { + $client = Mockery::mock(TypesenseClient::class); + $client->shouldNotReceive('getCollections'); + + $engine = $this->createEngine($client); + + $engine->delete(new EloquentCollection([])); + + $this->assertTrue(true); + } + + public function testDeleteDocumentReturnsEmptyArrayWhenDocumentNotFound(): void + { + $model = $this->createSearchableModelMock(); + $model->shouldReceive('getScoutKey')->andReturn(123); + + // Mock the Document object to throw ObjectNotFound on retrieve + $document = Mockery::mock(Document::class); + $document->shouldReceive('retrieve')->once()->andThrow(new ObjectNotFound('Document not found')); + $document->shouldNotReceive('delete'); + + $documents = Mockery::mock(Documents::class); + $documents->shouldReceive('offsetGet') + ->with('123') + ->andReturn($document); + + $collection = Mockery::mock(TypesenseCollection::class); + $collection->shouldReceive('getDocuments')->andReturn($documents); + + $engine = $this->createPartialEngine(); + $engine->shouldReceive('getOrCreateCollectionFromModel') + ->once() + ->with($model, null, false) + ->andReturn($collection); + + // Should not throw - idempotent delete + $engine->delete(new EloquentCollection([$model])); + + $this->assertTrue(true); + } + + public function testDeleteDocumentThrowsOnNonNotFoundErrors(): void + { + $model = $this->createSearchableModelMock(); + $model->shouldReceive('getScoutKey')->andReturn(123); + + // Mock the Document object to throw TypesenseClientError (network/auth error) + $document = Mockery::mock(Document::class); + $document->shouldReceive('retrieve')->once()->andThrow(new TypesenseClientError('Connection failed')); + + $documents = Mockery::mock(Documents::class); + $documents->shouldReceive('offsetGet') + ->with('123') + ->andReturn($document); + + $collection = Mockery::mock(TypesenseCollection::class); + $collection->shouldReceive('getDocuments')->andReturn($documents); + + $engine = $this->createPartialEngine(); + $engine->shouldReceive('getOrCreateCollectionFromModel') + ->once() + ->with($model, null, false) + ->andReturn($collection); + + $this->expectException(TypesenseClientError::class); + $this->expectExceptionMessage('Connection failed'); + + $engine->delete(new EloquentCollection([$model])); + } + + public function testFlushDeletesCollection(): void + { + $model = $this->createSearchableModelMock(); + + $collection = Mockery::mock(TypesenseCollection::class); + $collection->shouldReceive('delete')->once(); + + $engine = $this->createPartialEngine(); + $engine->shouldReceive('getOrCreateCollectionFromModel') + ->once() + ->with($model) + ->andReturn($collection); + + $engine->flush($model); + } + + public function testDeleteIndexCallsTypesenseDelete(): void + { + $collection = Mockery::mock(TypesenseCollection::class); + $collection->shouldReceive('delete') + ->once() + ->andReturn(['name' => 'test_index']); + + // Create a test double that extends Collections to satisfy return type + $collections = new class($collection) extends \Typesense\Collections { + private $mockCollection; + + public function __construct($mockCollection) + { + // Don't call parent constructor - we're mocking + $this->mockCollection = $mockCollection; + } + + public function __get($name) + { + return $this->mockCollection; + } + }; + + $client = Mockery::mock(TypesenseClient::class); + $client->shouldReceive('getCollections')->andReturn($collections); + + $engine = $this->createEngine($client); + + $result = $engine->deleteIndex('test_index'); + + $this->assertEquals(['name' => 'test_index'], $result); + } + + public function testGetTypesenseClientReturnsClient(): void + { + $client = Mockery::mock(TypesenseClient::class); + $engine = $this->createEngine($client); + + $this->assertSame($client, $engine->getTypesenseClient()); + } + + public function testMapReturnsEmptyCollectionWhenNoResults(): void + { + $engine = $this->createEngine(); + + $model = $this->createSearchableModelMock(); + $model->shouldReceive('newCollection')->andReturn(new EloquentCollection()); + + $builder = Mockery::mock(Builder::class); + $results = ['found' => 0, 'hits' => []]; + + $mapped = $engine->map($builder, $results, $model); + + $this->assertTrue($mapped->isEmpty()); + } + + public function testLazyMapReturnsLazyCollectionWhenNoResults(): void + { + $engine = $this->createEngine(); + + $model = $this->createSearchableModelMock(); + $model->shouldReceive('newCollection')->andReturn(new EloquentCollection()); + + $builder = Mockery::mock(Builder::class); + $results = ['found' => 0, 'hits' => []]; + + $lazyMapped = $engine->lazyMap($builder, $results, $model); + + $this->assertInstanceOf(\Hypervel\Support\LazyCollection::class, $lazyMapped); + } + + public function testBuildSearchParametersIncludesBasicParameters(): void + { + $engine = $this->createPartialEngineWithConfig(); + + $model = $this->createSearchableModelMock(); + + $builder = Mockery::mock(Builder::class); + $builder->model = $model; + $builder->query = 'search term'; + $builder->wheres = []; + $builder->whereIns = []; + $builder->whereNotIns = []; + $builder->orders = []; + $builder->options = []; + + $params = $engine->buildSearchParameters($builder, 1, 25); + + $this->assertSame('search term', $params['q']); + $this->assertSame(1, $params['page']); + $this->assertSame(25, $params['per_page']); + $this->assertArrayHasKey('query_by', $params); + $this->assertArrayHasKey('filter_by', $params); + $this->assertArrayHasKey('highlight_start_tag', $params); + $this->assertArrayHasKey('highlight_end_tag', $params); + } + + public function testBuildSearchParametersIncludesFilters(): void + { + $engine = $this->createPartialEngineWithConfig(); + + $model = $this->createSearchableModelMock(); + + $builder = Mockery::mock(Builder::class); + $builder->model = $model; + $builder->query = 'test'; + $builder->wheres = ['status' => 'active']; + $builder->whereIns = ['category' => ['a', 'b']]; + $builder->whereNotIns = ['brand' => ['x']]; + $builder->orders = []; + $builder->options = []; + + $params = $engine->buildSearchParameters($builder, 1, 10); + + $this->assertStringContainsString('status:=active', $params['filter_by']); + $this->assertStringContainsString('category:=[a, b]', $params['filter_by']); + $this->assertStringContainsString('brand:!=[x]', $params['filter_by']); + } + + public function testBuildSearchParametersMergesBuilderOptions(): void + { + $engine = $this->createPartialEngineWithConfig(); + + $model = $this->createSearchableModelMock(); + + $builder = Mockery::mock(Builder::class); + $builder->model = $model; + $builder->query = 'test'; + $builder->wheres = []; + $builder->whereIns = []; + $builder->whereNotIns = []; + $builder->orders = []; + $builder->options = [ + 'exhaustive_search' => true, + 'custom_param' => 'value', + ]; + + $params = $engine->buildSearchParameters($builder, 1, 10); + + $this->assertTrue($params['exhaustive_search']); + $this->assertSame('value', $params['custom_param']); + } + + public function testBuildSearchParametersIncludesSortBy(): void + { + $engine = $this->createPartialEngineWithConfig(); + + $model = $this->createSearchableModelMock(); + + $builder = Mockery::mock(Builder::class); + $builder->model = $model; + $builder->query = 'test'; + $builder->wheres = []; + $builder->whereIns = []; + $builder->whereNotIns = []; + $builder->orders = [ + ['column' => 'name', 'direction' => 'asc'], + ['column' => 'created_at', 'direction' => 'desc'], + ]; + $builder->options = []; + + $params = $engine->buildSearchParameters($builder, 1, 10); + + $this->assertSame('name:asc,created_at:desc', $params['sort_by']); + } + + public function testBuildSearchParametersAppendsToExistingSortBy(): void + { + $engine = $this->createPartialEngineWithConfig(); + + $model = $this->createSearchableModelMock(); + + $builder = Mockery::mock(Builder::class); + $builder->model = $model; + $builder->query = 'test'; + $builder->wheres = []; + $builder->whereIns = []; + $builder->whereNotIns = []; + $builder->orders = [ + ['column' => 'name', 'direction' => 'asc'], + ]; + $builder->options = [ + 'sort_by' => '_text_match:desc', + ]; + + $params = $engine->buildSearchParameters($builder, 1, 10); + + $this->assertSame('_text_match:desc,name:asc', $params['sort_by']); + } + + public function testBuildSearchParametersWithDifferentPageAndPerPage(): void + { + $engine = $this->createPartialEngineWithConfig(); + + $model = $this->createSearchableModelMock(); + + $builder = Mockery::mock(Builder::class); + $builder->model = $model; + $builder->query = 'query'; + $builder->wheres = []; + $builder->whereIns = []; + $builder->whereNotIns = []; + $builder->orders = []; + $builder->options = []; + + $params = $engine->buildSearchParameters($builder, 5, 50); + + $this->assertSame(5, $params['page']); + $this->assertSame(50, $params['per_page']); + } + + public function testBuildSearchParametersWithEmptyQuery(): void + { + $engine = $this->createPartialEngineWithConfig(); + + $model = $this->createSearchableModelMock(); + + $builder = Mockery::mock(Builder::class); + $builder->model = $model; + $builder->query = ''; + $builder->wheres = []; + $builder->whereIns = []; + $builder->whereNotIns = []; + $builder->orders = []; + $builder->options = []; + + $params = $engine->buildSearchParameters($builder, 1, 10); + + $this->assertSame('', $params['q']); + $this->assertSame('', $params['filter_by']); + } + + /** + * Create a partial engine mock that stubs getConfig to avoid ApplicationContext dependency. + */ + protected function createPartialEngineWithConfig(?MockInterface $client = null): MockInterface&TypesenseEngine + { + $client = $client ?? Mockery::mock(TypesenseClient::class); + + /** @var MockInterface&TypesenseEngine */ + $engine = Mockery::mock(TypesenseEngine::class, [$client, 1000]) + ->shouldAllowMockingProtectedMethods() + ->makePartial(); + + $engine->shouldReceive('getConfig') + ->andReturnUsing(function (string $key, mixed $default = null) { + // Return empty array for model-settings (no custom search params) + if (str_starts_with($key, 'typesense.model-settings.')) { + return $default; + } + + return $default; + }); + + return $engine; + } +} diff --git a/tests/Scout/Unit/Jobs/RemoveFromSearchTest.php b/tests/Scout/Unit/Jobs/RemoveFromSearchTest.php new file mode 100644 index 000000000..b97ac8c9b --- /dev/null +++ b/tests/Scout/Unit/Jobs/RemoveFromSearchTest.php @@ -0,0 +1,98 @@ + 'First', 'body' => 'Content']); + $model1->id = 1; + + $model2 = new SearchableModel(['title' => 'Second', 'body' => 'Content']); + $model2->id = 2; + + $collection = new Collection([$model1, $model2]); + + $engine = Mockery::mock(\Hypervel\Scout\Engine::class); + $engine->shouldReceive('delete') + ->once() + ->with(Mockery::on(function ($models) { + return $models instanceof RemoveableScoutCollection + && $models->count() === 2; + })); + + $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { + public function __construct(private $engine) + { + } + + public function engine(): \Hypervel\Scout\Engine + { + return $this->engine; + } + }); + + $job = new RemoveFromSearch($collection); + $job->handle(); + } + + public function testHandleDoesNothingForEmptyCollection(): void + { + $collection = new Collection([]); + + $engine = Mockery::mock(\Hypervel\Scout\Engine::class); + $engine->shouldNotReceive('delete'); + + $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { + public function __construct(private $engine) + { + } + + public function engine(): \Hypervel\Scout\Engine + { + return $this->engine; + } + }); + + $job = new RemoveFromSearch($collection); + $job->handle(); + + // If we get here without exception, the test passes + $this->assertTrue(true); + } + + public function testConstructorWrapsCollectionInRemoveableScoutCollection(): void + { + $model = new SearchableModel(['title' => 'Test', 'body' => 'Content']); + $model->id = 1; + + $collection = new Collection([$model]); + + $job = new RemoveFromSearch($collection); + + $this->assertInstanceOf(RemoveableScoutCollection::class, $job->models); + $this->assertCount(1, $job->models); + } +} diff --git a/tests/Scout/Unit/Jobs/RemoveableScoutCollectionTest.php b/tests/Scout/Unit/Jobs/RemoveableScoutCollectionTest.php new file mode 100644 index 000000000..ff0896db7 --- /dev/null +++ b/tests/Scout/Unit/Jobs/RemoveableScoutCollectionTest.php @@ -0,0 +1,77 @@ + 'First', 'body' => 'Content']); + $model1->id = 1; + + $model2 = new SearchableModel(['title' => 'Second', 'body' => 'Content']); + $model2->id = 2; + + $collection = RemoveableScoutCollection::make([$model1, $model2]); + + $this->assertEquals([1, 2], $collection->getQueueableIds()); + } + + public function testGetQueueableIdsResolvesCustomScoutKeys(): void + { + $model1 = new CustomScoutKeyModel(['title' => 'First', 'body' => 'Content']); + $model1->id = 1; + + $model2 = new CustomScoutKeyModel(['title' => 'Second', 'body' => 'Content']); + $model2->id = 2; + + $model3 = new CustomScoutKeyModel(['title' => 'Third', 'body' => 'Content']); + $model3->id = 3; + + $collection = RemoveableScoutCollection::make([$model1, $model2, $model3]); + + $this->assertEquals([ + 'custom-key.1', + 'custom-key.2', + 'custom-key.3', + ], $collection->getQueueableIds()); + } + + public function testGetQueueableIdsReturnsEmptyArrayForEmptyCollection(): void + { + $collection = RemoveableScoutCollection::make([]); + + $this->assertEquals([], $collection->getQueueableIds()); + } + + public function testGetQueueableIdsWithMixedModels(): void + { + // Mix of standard and custom Scout key models + $standard1 = new SearchableModel(['title' => 'Standard', 'body' => 'Content']); + $standard1->id = 100; + + $custom1 = new CustomScoutKeyModel(['title' => 'Custom', 'body' => 'Content']); + $custom1->id = 200; + + // When collection has mixed models, the first model's behavior determines the path + // Standard model first - uses standard Scout keys + $collection1 = RemoveableScoutCollection::make([$standard1, $custom1]); + $ids1 = $collection1->getQueueableIds(); + + // Both models use Searchable trait, so getScoutKey() is called on each + $this->assertEquals([100, 'custom-key.200'], $ids1); + } +} diff --git a/tests/Scout/Unit/MakeSearchableUsingTest.php b/tests/Scout/Unit/MakeSearchableUsingTest.php new file mode 100644 index 000000000..84fa91939 --- /dev/null +++ b/tests/Scout/Unit/MakeSearchableUsingTest.php @@ -0,0 +1,202 @@ + 'Published Post', 'body' => 'Content']); + $published->id = 1; + + $draft = new FilteringSearchableModel(['title' => 'Draft: Work in Progress', 'body' => 'Content']); + $draft->id = 2; + + $collection = new Collection([$published, $draft]); + + // Mock the engine to verify what gets passed to update() + $engine = Mockery::mock(\Hypervel\Scout\Engine::class); + $engine->shouldReceive('update') + ->once() + ->with(Mockery::on(function ($models) { + // Should only contain the published model, not the draft + return $models->count() === 1 + && $models->first()->id === 1 + && $models->first()->title === 'Published Post'; + })); + + // Replace the engine + $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { + public function __construct(private $engine) + { + } + + public function engine(): \Hypervel\Scout\Engine + { + return $this->engine; + } + }); + + $published->syncMakeSearchable($collection); + } + + public function testSyncMakeSearchableHandlesEmptyFilteredCollection(): void + { + // Create only draft models that will be filtered out + $draft1 = new FilteringSearchableModel(['title' => 'Draft: First', 'body' => 'Content']); + $draft1->id = 1; + + $draft2 = new FilteringSearchableModel(['title' => 'Draft: Second', 'body' => 'Content']); + $draft2->id = 2; + + $collection = new Collection([$draft1, $draft2]); + + // Mock the engine - update should NOT be called + $engine = Mockery::mock(\Hypervel\Scout\Engine::class); + $engine->shouldNotReceive('update'); + + $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { + public function __construct(private $engine) + { + } + + public function engine(): \Hypervel\Scout\Engine + { + return $this->engine; + } + }); + + // This should not throw, even though all models are filtered out + $draft1->syncMakeSearchable($collection); + + // If we get here without exception, the test passes + $this->assertTrue(true); + } + + public function testMakeSearchableJobPassesFilteredCollectionToEngine(): void + { + // Create models - one published, one draft + $published = new FilteringSearchableModel(['title' => 'Published Post', 'body' => 'Content']); + $published->id = 1; + + $draft = new FilteringSearchableModel(['title' => 'Draft: Work in Progress', 'body' => 'Content']); + $draft->id = 2; + + $collection = new Collection([$published, $draft]); + + // Mock the engine to verify what gets passed to update() + $engine = Mockery::mock(\Hypervel\Scout\Engine::class); + $engine->shouldReceive('update') + ->once() + ->with(Mockery::on(function ($models) { + // Should only contain the published model, not the draft + return $models->count() === 1 + && $models->first()->id === 1 + && $models->first()->title === 'Published Post'; + })); + + $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { + public function __construct(private $engine) + { + } + + public function engine(): \Hypervel\Scout\Engine + { + return $this->engine; + } + }); + + $job = new MakeSearchable($collection); + $job->handle(); + } + + public function testMakeSearchableJobHandlesEmptyFilteredCollection(): void + { + // Create only draft models that will be filtered out + $draft1 = new FilteringSearchableModel(['title' => 'Draft: First', 'body' => 'Content']); + $draft1->id = 1; + + $draft2 = new FilteringSearchableModel(['title' => 'Draft: Second', 'body' => 'Content']); + $draft2->id = 2; + + $collection = new Collection([$draft1, $draft2]); + + // Mock the engine - update should NOT be called + $engine = Mockery::mock(\Hypervel\Scout\Engine::class); + $engine->shouldNotReceive('update'); + + $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { + public function __construct(private $engine) + { + } + + public function engine(): \Hypervel\Scout\Engine + { + return $this->engine; + } + }); + + $job = new MakeSearchable($collection); + + // This should not throw, even though all models are filtered out + $job->handle(); + + // If we get here without exception, the test passes + $this->assertTrue(true); + } + + public function testMakeSearchableUsingDefaultBehaviorPassesThroughUnchanged(): void + { + // Using the regular SearchableModel which doesn't override makeSearchableUsing + $model1 = new SearchableModel(['title' => 'First', 'body' => 'Content']); + $model1->id = 1; + + $model2 = new SearchableModel(['title' => 'Second', 'body' => 'Content']); + $model2->id = 2; + + $collection = new Collection([$model1, $model2]); + + // Mock the engine to verify all models are passed + $engine = Mockery::mock(\Hypervel\Scout\Engine::class); + $engine->shouldReceive('update') + ->once() + ->with(Mockery::on(function ($models) { + return $models->count() === 2; + })); + + $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { + public function __construct(private $engine) + { + } + + public function engine(): \Hypervel\Scout\Engine + { + return $this->engine; + } + }); + + $model1->syncMakeSearchable($collection); + } +} diff --git a/tests/Scout/Unit/QueueDispatchTest.php b/tests/Scout/Unit/QueueDispatchTest.php new file mode 100644 index 000000000..d220aa9b3 --- /dev/null +++ b/tests/Scout/Unit/QueueDispatchTest.php @@ -0,0 +1,200 @@ +app->get(ConfigInterface::class)->set('scout.queue.enabled', true); + $this->app->get(ConfigInterface::class)->set('scout.queue.after_commit', false); + + Bus::fake([MakeSearchable::class]); + + $model = new SearchableModel(['title' => 'Test', 'body' => 'Content']); + $model->id = 1; + + $model->queueMakeSearchable(new Collection([$model])); + + Bus::assertDispatched(MakeSearchable::class, function (MakeSearchable $job) { + // afterCommit should be null or false when after_commit config is disabled + return $job->afterCommit !== true; + }); + } + + public function testQueueMakeSearchableDispatchesWithAfterCommitWhenEnabled(): void + { + $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', true); + $this->app->get(ConfigInterface::class)->set('scout.queue.after_commit', true); + + Bus::fake([MakeSearchable::class]); + + $model = new SearchableModel(['title' => 'Test', 'body' => 'Content']); + $model->id = 1; + + $model->queueMakeSearchable(new Collection([$model])); + + Bus::assertDispatched(MakeSearchable::class, function (MakeSearchable $job) { + return $job->afterCommit === true; + }); + } + + public function testQueueRemoveFromSearchDispatchesJobWhenQueueEnabled(): void + { + $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', true); + $this->app->get(ConfigInterface::class)->set('scout.queue.after_commit', false); + + Bus::fake([RemoveFromSearch::class]); + + $model = new SearchableModel(['title' => 'Test', 'body' => 'Content']); + $model->id = 1; + + $model->queueRemoveFromSearch(new Collection([$model])); + + Bus::assertDispatched(RemoveFromSearch::class, function (RemoveFromSearch $job) { + // afterCommit should be null or false when after_commit config is disabled + return $job->afterCommit !== true; + }); + } + + public function testQueueRemoveFromSearchDispatchesWithAfterCommitWhenEnabled(): void + { + $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', true); + $this->app->get(ConfigInterface::class)->set('scout.queue.after_commit', true); + + Bus::fake([RemoveFromSearch::class]); + + $model = new SearchableModel(['title' => 'Test', 'body' => 'Content']); + $model->id = 1; + + $model->queueRemoveFromSearch(new Collection([$model])); + + Bus::assertDispatched(RemoveFromSearch::class, function (RemoveFromSearch $job) { + return $job->afterCommit === true; + }); + } + + public function testQueueMakeSearchableDoesNotDispatchJobWhenQueueDisabled(): void + { + $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', false); + + Bus::fake([MakeSearchable::class]); + + $model = new SearchableModel(['title' => 'Test', 'body' => 'Content']); + $model->id = 1; + + $model->queueMakeSearchable(new Collection([$model])); + + // When queue is disabled, the job should not be dispatched via Bus + // Instead, it uses Coroutine::defer() or direct execution + Bus::assertNotDispatched(MakeSearchable::class); + } + + public function testQueueRemoveFromSearchDoesNotDispatchJobWhenQueueDisabled(): void + { + $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', false); + + Bus::fake([RemoveFromSearch::class]); + + $model = new SearchableModel(['title' => 'Test', 'body' => 'Content']); + $model->id = 1; + + $model->queueRemoveFromSearch(new Collection([$model])); + + // When queue is disabled, the job should not be dispatched via Bus + Bus::assertNotDispatched(RemoveFromSearch::class); + } + + public function testEmptyCollectionDoesNotDispatchMakeSearchableJob(): void + { + $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', true); + + Bus::fake([MakeSearchable::class]); + + $model = new SearchableModel(); + $model->queueMakeSearchable(new Collection([])); + + Bus::assertNotDispatched(MakeSearchable::class); + } + + public function testEmptyCollectionDoesNotDispatchRemoveFromSearchJob(): void + { + $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', true); + + Bus::fake([RemoveFromSearch::class]); + + $model = new SearchableModel(); + $model->queueRemoveFromSearch(new Collection([])); + + Bus::assertNotDispatched(RemoveFromSearch::class); + } + + public function testQueueMakeSearchableDispatchesCustomJobClass(): void + { + $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', true); + + Scout::makeSearchableUsing(TestCustomMakeSearchable::class); + + Bus::fake([TestCustomMakeSearchable::class]); + + $model = new SearchableModel(['title' => 'Test', 'body' => 'Content']); + $model->id = 1; + + $model->queueMakeSearchable(new Collection([$model])); + + Bus::assertDispatched(TestCustomMakeSearchable::class); + } + + public function testQueueRemoveFromSearchDispatchesCustomJobClass(): void + { + $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', true); + + Scout::removeFromSearchUsing(TestCustomRemoveFromSearch::class); + + Bus::fake([TestCustomRemoveFromSearch::class]); + + $model = new SearchableModel(['title' => 'Test', 'body' => 'Content']); + $model->id = 1; + + $model->queueRemoveFromSearch(new Collection([$model])); + + Bus::assertDispatched(TestCustomRemoveFromSearch::class); + } +} + +/** + * Custom job class for testing custom MakeSearchable dispatch. + */ +class TestCustomMakeSearchable extends MakeSearchable +{ +} + +/** + * Custom job class for testing custom RemoveFromSearch dispatch. + */ +class TestCustomRemoveFromSearch extends RemoveFromSearch +{ +} diff --git a/tests/Scout/Unit/ScoutTest.php b/tests/Scout/Unit/ScoutTest.php new file mode 100644 index 000000000..50cca11b3 --- /dev/null +++ b/tests/Scout/Unit/ScoutTest.php @@ -0,0 +1,112 @@ +assertSame(MakeSearchable::class, Scout::$makeSearchableJob); + } + + public function testDefaultRemoveFromSearchJobClass(): void + { + $this->assertSame(RemoveFromSearch::class, Scout::$removeFromSearchJob); + } + + public function testMakeSearchableUsingChangesJobClass(): void + { + Scout::makeSearchableUsing(CustomMakeSearchable::class); + + $this->assertSame(CustomMakeSearchable::class, Scout::$makeSearchableJob); + } + + public function testRemoveFromSearchUsingChangesJobClass(): void + { + Scout::removeFromSearchUsing(CustomRemoveFromSearch::class); + + $this->assertSame(CustomRemoveFromSearch::class, Scout::$removeFromSearchJob); + } + + public function testResetJobClassesRestoresDefaults(): void + { + Scout::makeSearchableUsing(CustomMakeSearchable::class); + Scout::removeFromSearchUsing(CustomRemoveFromSearch::class); + + Scout::resetJobClasses(); + + $this->assertSame(MakeSearchable::class, Scout::$makeSearchableJob); + $this->assertSame(RemoveFromSearch::class, Scout::$removeFromSearchJob); + } + + public function testEngineMethodReturnsEngineFromManager(): void + { + $engine = m::mock(Engine::class); + + $manager = m::mock(EngineManager::class); + $manager->shouldReceive('engine') + ->with('meilisearch') + ->once() + ->andReturn($engine); + + $this->app->instance(EngineManager::class, $manager); + + $result = Scout::engine('meilisearch'); + + $this->assertSame($engine, $result); + } + + public function testEngineMethodWithNullUsesDefaultEngine(): void + { + $engine = m::mock(Engine::class); + + $manager = m::mock(EngineManager::class); + $manager->shouldReceive('engine') + ->with(null) + ->once() + ->andReturn($engine); + + $this->app->instance(EngineManager::class, $manager); + + $result = Scout::engine(); + + $this->assertSame($engine, $result); + } +} + +/** + * Custom job class for testing makeSearchableUsing(). + */ +class CustomMakeSearchable extends MakeSearchable +{ +} + +/** + * Custom job class for testing removeFromSearchUsing(). + */ +class CustomRemoveFromSearch extends RemoveFromSearch +{ +} diff --git a/tests/Scout/migrations/2025_01_01_000000_create_searchable_models_table.php b/tests/Scout/migrations/2025_01_01_000000_create_searchable_models_table.php new file mode 100644 index 000000000..fcd96f71d --- /dev/null +++ b/tests/Scout/migrations/2025_01_01_000000_create_searchable_models_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('title'); + $table->text('body')->nullable(); + $table->timestamps(); + }); + + Schema::create('soft_deletable_searchable_models', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->text('body')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } +}; diff --git a/tests/Support/MeilisearchIntegrationTestCase.php b/tests/Support/MeilisearchIntegrationTestCase.php new file mode 100644 index 000000000..f0c4e1aeb --- /dev/null +++ b/tests/Support/MeilisearchIntegrationTestCase.php @@ -0,0 +1,178 @@ + + */ + protected array $createdIndexes = []; + + protected function setUp(): void + { + if (! env('RUN_MEILISEARCH_INTEGRATION_TESTS', false)) { + $this->markTestSkipped( + 'Meilisearch integration tests are disabled. Set RUN_MEILISEARCH_INTEGRATION_TESTS=true to enable.' + ); + } + + $this->computeTestPrefix(); + + parent::setUp(); + + $this->app->register(ScoutServiceProvider::class); + $this->configureMeilisearch(); + } + + /** + * Initialize the Meilisearch client and clean up indexes. + * + * Subclasses using RunTestsInCoroutine should call this in setUpInCoroutine(). + * Subclasses NOT using the trait should call this at the end of setUp(). + */ + protected function initializeMeilisearch(): void + { + $this->meilisearch = $this->app->get(MeilisearchClient::class); + $this->cleanupTestIndexes(); + } + + protected function tearDown(): void + { + if (isset($this->meilisearch)) { + $this->cleanupTestIndexes(); + } + + parent::tearDown(); + } + + /** + * Compute parallel-safe prefix based on TEST_TOKEN from paratest. + */ + protected function computeTestPrefix(): void + { + $testToken = env('TEST_TOKEN', ''); + + if ($testToken !== '') { + $this->testPrefix = "{$this->basePrefix}_{$testToken}_"; + } else { + $this->testPrefix = "{$this->basePrefix}_"; + } + } + + /** + * Configure Meilisearch from environment variables. + */ + protected function configureMeilisearch(): void + { + $config = $this->app->get(ConfigInterface::class); + + $host = env('MEILISEARCH_HOST', '127.0.0.1'); + $port = env('MEILISEARCH_PORT', '7700'); + $key = env('MEILISEARCH_KEY', ''); + + $config->set('scout.driver', 'meilisearch'); + $config->set('scout.prefix', $this->testPrefix); + $config->set('scout.meilisearch.host', "http://{$host}:{$port}"); + $config->set('scout.meilisearch.key', $key); + } + + /** + * Get a prefixed index name. + */ + protected function prefixedIndexName(string $name): string + { + return $this->testPrefix . $name; + } + + /** + * Create a test index and track it for cleanup. + * + * @param array $options + */ + protected function createTestIndex(string $name, array $options = []): void + { + $indexName = $this->prefixedIndexName($name); + $this->meilisearch->createIndex($indexName, $options); + $this->createdIndexes[] = $indexName; + } + + /** + * Clean up all test indexes matching the test prefix. + */ + protected function cleanupTestIndexes(): void + { + try { + $indexes = $this->meilisearch->getIndexes(); + + foreach ($indexes->getResults() as $index) { + if (str_starts_with($index->getUid(), $this->testPrefix)) { + $this->meilisearch->deleteIndex($index->getUid()); + } + } + } catch (Throwable) { + // Ignore errors during cleanup + } + + $this->createdIndexes = []; + } + + /** + * Wait for Meilisearch tasks to complete. + */ + protected function waitForTasks(int $timeoutMs = 5000): void + { + try { + $tasks = $this->meilisearch->getTasks(); + foreach ($tasks->getResults() as $task) { + if (in_array($task['status'], ['enqueued', 'processing'], true)) { + $this->meilisearch->waitForTask($task['uid'], $timeoutMs); + } + } + } catch (Throwable) { + // Ignore timeout errors + } + } +} diff --git a/tests/Support/TypesenseIntegrationTestCase.php b/tests/Support/TypesenseIntegrationTestCase.php new file mode 100644 index 000000000..a6e1cd6ab --- /dev/null +++ b/tests/Support/TypesenseIntegrationTestCase.php @@ -0,0 +1,173 @@ + + */ + protected array $createdCollections = []; + + protected function setUp(): void + { + if (! env('RUN_TYPESENSE_INTEGRATION_TESTS', false)) { + $this->markTestSkipped( + 'Typesense integration tests are disabled. Set RUN_TYPESENSE_INTEGRATION_TESTS=true to enable.' + ); + } + + $this->computeTestPrefix(); + + parent::setUp(); + + $this->app->register(ScoutServiceProvider::class); + $this->configureTypesense(); + } + + /** + * Initialize the Typesense client and clean up collections. + * + * Subclasses using RunTestsInCoroutine should call this in setUpInCoroutine(). + * Subclasses NOT using the trait should call this at the end of setUp(). + */ + protected function initializeTypesense(): void + { + $this->typesense = $this->app->get(TypesenseClient::class); + $this->cleanupTestCollections(); + } + + protected function tearDown(): void + { + if (isset($this->typesense)) { + $this->cleanupTestCollections(); + } + + parent::tearDown(); + } + + /** + * Compute parallel-safe prefix based on TEST_TOKEN from paratest. + */ + protected function computeTestPrefix(): void + { + $testToken = env('TEST_TOKEN', ''); + + if ($testToken !== '') { + $this->testPrefix = "{$this->basePrefix}_{$testToken}_"; + } else { + $this->testPrefix = "{$this->basePrefix}_"; + } + } + + /** + * Configure Typesense from environment variables. + */ + protected function configureTypesense(): void + { + $config = $this->app->get(ConfigInterface::class); + + $host = env('TYPESENSE_HOST', '127.0.0.1'); + $port = env('TYPESENSE_PORT', '8108'); + $protocol = env('TYPESENSE_PROTOCOL', 'http'); + $apiKey = env('TYPESENSE_API_KEY', ''); + + $config->set('scout.driver', 'typesense'); + $config->set('scout.prefix', $this->testPrefix); + $config->set('scout.typesense.client-settings', [ + 'api_key' => $apiKey, + 'nodes' => [ + [ + 'host' => $host, + 'port' => $port, + 'protocol' => $protocol, + ], + ], + 'connection_timeout_seconds' => 2, + ]); + } + + /** + * Get a prefixed collection name. + */ + protected function prefixedCollectionName(string $name): string + { + return $this->testPrefix . $name; + } + + /** + * Create a test collection and track it for cleanup. + * + * @param array $schema + */ + protected function createTestCollection(string $name, array $schema): void + { + $collectionName = $this->prefixedCollectionName($name); + $schema['name'] = $collectionName; + + $this->typesense->collections->create($schema); + $this->createdCollections[] = $collectionName; + } + + /** + * Clean up all test collections matching the test prefix. + */ + protected function cleanupTestCollections(): void + { + try { + $collections = $this->typesense->collections->retrieve(); + + foreach ($collections as $collection) { + if (str_starts_with($collection['name'], $this->testPrefix)) { + $this->typesense->collections[$collection['name']]->delete(); + } + } + } catch (Throwable) { + // Ignore errors during cleanup + } + + $this->createdCollections = []; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 000000000..b041b853c --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,22 @@ +load(); +}