diff --git a/composer.json b/composer.json index ecf473b7..c2aa0f81 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,9 @@ "illuminate/view": "^9|^10" }, "require-dev": { + "algolia/algoliasearch-client-php": "^3.4", + "laravel/scout": "^10.5", + "meilisearch/meilisearch-php": "^1.4", "nunomaduro/larastan": "^2.4", "orchestra/testbench": "^8", "yajra/laravel-datatables-html": "^9.3.4|^10" @@ -60,7 +63,10 @@ } }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "php-http/discovery": true + } }, "scripts": { "test": "vendor/bin/phpunit" diff --git a/src/CollectionDataTable.php b/src/CollectionDataTable.php index 4c195f83..99e7da1a 100644 --- a/src/CollectionDataTable.php +++ b/src/CollectionDataTable.php @@ -334,10 +334,10 @@ protected function getSorter(array $criteria): Closure /** * Resolve callback parameter instance. * - * @return static + * @return array */ - protected function resolveCallbackParameter(): self + protected function resolveCallbackParameter(): array { - return $this; + return [$this, false]; } } diff --git a/src/DataTableAbstract.php b/src/DataTableAbstract.php index 7814c7f1..90ff9709 100644 --- a/src/DataTableAbstract.php +++ b/src/DataTableAbstract.php @@ -681,7 +681,7 @@ protected function isBlacklisted($column): bool public function ordering(): void { if ($this->orderCallback) { - call_user_func($this->orderCallback, $this->resolveCallbackParameter()); + call_user_func_array($this->orderCallback, $this->resolveCallbackParameter()); } else { $this->defaultOrdering(); } @@ -690,7 +690,7 @@ public function ordering(): void /** * Resolve callback parameter instance. * - * @return mixed + * @return array */ abstract protected function resolveCallbackParameter(); @@ -776,7 +776,7 @@ protected function filterRecords(): void } if (is_callable($this->filterCallback)) { - call_user_func($this->filterCallback, $this->resolveCallbackParameter()); + call_user_func_array($this->filterCallback, $this->resolveCallbackParameter()); } $this->columnSearch(); diff --git a/src/QueryDataTable.php b/src/QueryDataTable.php index 53a1d23d..b7336176 100644 --- a/src/QueryDataTable.php +++ b/src/QueryDataTable.php @@ -5,6 +5,7 @@ use Illuminate\Contracts\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Contracts\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Connection; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Query\Expression; use Illuminate\Http\JsonResponse; use Illuminate\Support\Collection; @@ -56,6 +57,56 @@ class QueryDataTable extends DataTableAbstract */ protected bool $ignoreSelectInCountQuery = false; + /** + * Enable scout search and use this model for searching. + * + * @var Model|null + */ + protected ?Model $scoutModel = null; + + /** + * Maximum number of hits to return from scout. + * + * @var int + */ + protected int $scoutMaxHits = 1000; + + /** + * Add dynamic filters to scout search. + * + * @var callable|null + */ + protected $scoutFilterCallback = null; + + /** + * Flag if scout search was performed. + * + * @var bool + */ + protected bool $scoutSearched = false; + + /** + * Scout index name. + * + * @var string + */ + protected string $scoutIndex; + + /** + * Scout key name. + * + * @var string + */ + protected string $scoutKey; + + /** + * Flag to disable user ordering if a fixed ordering was performed (e.g. scout search). + * Only works with corresponding javascript listener. + * + * @var bool + */ + protected $disableUserOrdering = false; + /** * @param QueryBuilder $builder */ @@ -237,7 +288,7 @@ protected function filterRecords(): void } if (is_callable($this->filterCallback)) { - call_user_func($this->filterCallback, $this->resolveCallbackParameter()); + call_user_func_array($this->filterCallback, $this->resolveCallbackParameter()); } $this->columnSearch(); @@ -673,11 +724,11 @@ protected function searchPanesSearch(): void /** * Resolve callback parameter instance. * - * @return QueryBuilder + * @return array */ - protected function resolveCallbackParameter() + protected function resolveCallbackParameter(): array { - return $this->query; + return [$this->query, $this->scoutSearched]; } /** @@ -778,6 +829,11 @@ protected function getNullsLastSql($column, $direction): string */ protected function globalSearch(string $keyword): void { + // Try scout search first & fall back to default search if disabled/failed + if ($this->applyScoutSearch($keyword)) { + return; + } + $this->query->where(function ($query) use ($keyword) { collect($this->request->searchableColumnIndex()) ->map(function ($index) { @@ -835,6 +891,9 @@ protected function attachAppends(array $data): array } } + // Set flag to disable ordering + $appends['disableOrdering'] = $this->disableUserOrdering; + return array_merge($data, $appends); } @@ -861,4 +920,210 @@ public function ignoreSelectsInCountQuery(): static return $this; } + + /** + * Perform sorting of columns. + * + * @return void + */ + public function ordering(): void + { + // Skip if user ordering is disabled (e.g. scout search) + if ($this->disableUserOrdering) { + return; + } + + parent::ordering(); + } + + /** + * Enable scout search and use provided model for searching. + * $max_hits is the maximum number of hits to return from scout. + * + * @param string $model + * @param int $max_hits + * @return $this + */ + public function enableScoutSearch(string $model, int $max_hits = 1000): static + { + $scout_model = new $model; + if (! class_exists($model) || ! ($scout_model instanceof Model)) { + throw new \Exception("$model must be an Eloquent Model."); + } + if (! method_exists($scout_model, 'searchableAs') || ! method_exists($scout_model, 'getScoutKeyName')) { + throw new \Exception("$model must use the Searchable trait."); + } + + $this->scoutModel = $scout_model; + $this->scoutMaxHits = $max_hits; + $this->scoutIndex = $this->scoutModel->searchableAs(); + $this->scoutKey = $this->scoutModel->getScoutKeyName(); + + return $this; + } + + /** + * Add dynamic filters to scout search. + * + * @param callable $callback + * @return $this + */ + public function scoutFilter(callable $callback): static + { + $this->scoutFilterCallback = $callback; + + return $this; + } + + /** + * Apply scout search to query if enabled. + * + * @param string $search_keyword + * @return bool + */ + protected function applyScoutSearch(string $search_keyword): bool + { + if ($this->scoutModel == null) { + return false; + } + + try { + // Perform scout search + $search_filters = ''; + if (is_callable($this->scoutFilterCallback)) { + $search_filters = ($this->scoutFilterCallback)($search_keyword); + } + + $search_results = $this->performScoutSearch($search_keyword, $search_filters); + + // Apply scout search results to query + $this->query->where(function ($query) use ($search_results) { + $this->query->whereIn($this->scoutKey, $search_results); + }); + + // Order by scout search results & disable user ordering (if db driver is supported) + if (count($search_results) > 0 && $this->applyFixedOrderingToQuery($this->scoutKey, $search_results)) { + // Disable user ordering because we already ordered by search relevancy + $this->disableUserOrdering = true; + } + + $this->scoutSearched = true; + + return true; + } catch (\Exception) { + // Scout search failed, fallback to default search + return false; + } + } + + /** + * Apply fixed ordering to query by a fixed set of values depending on database driver (used for scout search). + * + * Currently supported drivers: MySQL + * + * @param string $keyName + * @param array $orderedKeys + * @return bool + */ + protected function applyFixedOrderingToQuery(string $keyName, array $orderedKeys) + { + $connection = $this->getConnection(); + $driver_name = $connection->getDriverName(); + + // Escape keyName and orderedKeys + $rawKeyName = $keyName; + $keyName = $connection->escape($keyName); + $orderedKeys = collect($orderedKeys) + ->map(function ($value) use ($connection) { + return $connection->escape($value); + }); + + switch ($driver_name) { + case 'mysql': + // MySQL / MariaDB + $this->query->orderByRaw("FIELD($keyName, ".$orderedKeys->implode(',').')'); + return true; + + /* + TODO: test implementations, fix if necessary and uncomment + case 'pgsql': + // PostgreSQL + $this->query->orderByRaw("array_position(ARRAY[" . $orderedKeys->implode(',') . "], $keyName)"); + return true; + + */ + + case 'sqlite': + case 'sqlsrv': + // SQLite & Microsoft SQL Server + // Compatible with all SQL drivers (but ugly solution) + + $this->query->orderByRaw( + "CASE `$rawKeyName` " + . + $orderedKeys + ->map(fn($value, $index) => "WHEN $value THEN $index") + ->implode(' ') + . + " END" + ); + return true; + + default: + return false; + } + } + + /** + * Perform a scout search with the configured engine and given parameters. Return matching model IDs. + * + * @param string $searchKeyword + * @param mixed $searchFilters + * @return array + */ + protected function performScoutSearch(string $searchKeyword, mixed $searchFilters = []): array + { + if (! class_exists('\Laravel\Scout\EngineManager')) { + throw new \Exception('Laravel Scout is not installed.'); + } + $engine = app(\Laravel\Scout\EngineManager::class)->engine(); + + if ($engine instanceof \Laravel\Scout\Engines\MeilisearchEngine) { + /** @var \Meilisearch\Client $engine */ + $search_results = $engine + ->index($this->scoutIndex) + ->rawSearch($searchKeyword, [ + 'limit' => $this->scoutMaxHits, + 'attributesToRetrieve' => [$this->scoutKey], + 'filter' => $searchFilters, + ]); + + /** @var array> $hits */ + $hits = $search_results['hits'] ?? []; + + return collect($hits) + ->pluck($this->scoutKey) + ->all(); + } elseif ($engine instanceof \Laravel\Scout\Engines\AlgoliaEngine) { + /** @var \Algolia\AlgoliaSearch\SearchClient $engine */ + $algolia = $engine->initIndex($this->scoutIndex); + + $search_results = $algolia->search($searchKeyword, [ + 'offset' => 0, + 'length' => $this->scoutMaxHits, + 'attributesToRetrieve' => [$this->scoutKey], + 'attributesToHighlight' => [], + 'filters' => $searchFilters, + ]); + + /** @var array> $hits */ + $hits = $search_results['hits'] ?? []; + + return collect($hits) + ->pluck($this->scoutKey) + ->all(); + } else { + throw new \Exception('Unsupported Scout Engine. Currently supported: Meilisearch, Algolia'); + } + } }