44
common/Search/Controllers/NormalizedModelsController.php
Executable file
44
common/Search/Controllers/NormalizedModelsController.php
Executable file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Search\Controllers;
|
||||
|
||||
use Common\Core\BaseController;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NormalizedModelsController extends BaseController
|
||||
{
|
||||
public function show(string $modelType, int $modelId)
|
||||
{
|
||||
$namespace = modelTypeToNamespace($modelType);
|
||||
$with = request('with') ? explode(',', request('with')) : [];
|
||||
|
||||
$model = app($namespace)->findOrFail($modelId);
|
||||
$model->load($with);
|
||||
|
||||
$this->authorize('show', $model);
|
||||
|
||||
return $this->success(['model' => $model->toNormalizedArray()]);
|
||||
}
|
||||
|
||||
public function index(string $modelType)
|
||||
{
|
||||
$namespace = modelTypeToNamespace($modelType);
|
||||
$query = request('query');
|
||||
$with = request('with') ? explode(',', request('with')) : [];
|
||||
|
||||
$this->authorize('index', $namespace);
|
||||
|
||||
$model = app($namespace);
|
||||
if ($query) {
|
||||
$model = $model->mysqlSearch($query);
|
||||
}
|
||||
|
||||
$results = $model
|
||||
->take(15)
|
||||
->get()
|
||||
->load($with)
|
||||
->map(fn(Model $model) => $model->toNormalizedArray());
|
||||
|
||||
return $this->success(['results' => $results]);
|
||||
}
|
||||
}
|
||||
37
common/Search/Controllers/SearchSettingsController.php
Executable file
37
common/Search/Controllers/SearchSettingsController.php
Executable file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Search\Controllers;
|
||||
|
||||
use Artisan;
|
||||
use Common\Core\BaseController;
|
||||
use Common\Search\ImportRecordsIntoScout;
|
||||
use Str;
|
||||
|
||||
class SearchSettingsController extends BaseController
|
||||
{
|
||||
public function getSearchableModels()
|
||||
{
|
||||
$models = ImportRecordsIntoScout::getSearchableModels();
|
||||
|
||||
$models = array_map(function (string $model) {
|
||||
return [
|
||||
'model' => $model,
|
||||
'name' => Str::plural(last(explode('\\', $model))),
|
||||
];
|
||||
}, $models);
|
||||
|
||||
return $this->success(['models' => $models]);
|
||||
}
|
||||
|
||||
public function import()
|
||||
{
|
||||
$this->middleware('isAdmin');
|
||||
|
||||
(new ImportRecordsIntoScout())->execute(
|
||||
request('model'),
|
||||
request('driver'),
|
||||
);
|
||||
|
||||
return $this->success(['output' => nl2br(Artisan::output())]);
|
||||
}
|
||||
}
|
||||
78
common/Search/Drivers/Mysql/MysqlFullTextIndexer.php
Executable file
78
common/Search/Drivers/Mysql/MysqlFullTextIndexer.php
Executable file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Search\Drivers\Mysql;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MysqlFullTextIndexer
|
||||
{
|
||||
private string $tableName;
|
||||
|
||||
private string $indexName;
|
||||
|
||||
private array $searchableFields;
|
||||
|
||||
private bool $indexAlreadyExists;
|
||||
|
||||
public function createOrUpdateIndex(string $model): void
|
||||
{
|
||||
$model = new $model();
|
||||
$this->tableName =
|
||||
config('database.connections.mysql.prefix') . $model->getTable();
|
||||
$this->indexName = $model->searchableAs();
|
||||
|
||||
$this->searchableFields = $model->getSearchableKeys(true);
|
||||
|
||||
$this->indexAlreadyExists = $this->indexExists();
|
||||
|
||||
if (!$this->indexAlreadyExists || $this->indexNeedsUpdate()) {
|
||||
$this->dropIndex();
|
||||
$fields = implode(',', $this->searchableFields);
|
||||
DB::statement(
|
||||
"CREATE FULLTEXT INDEX $this->indexName ON $this->tableName ($fields)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function indexExists(): bool
|
||||
{
|
||||
return !empty(
|
||||
DB::select("SHOW INDEX FROM $this->tableName WHERE Key_name = ?", [
|
||||
$this->indexName,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
private function indexNeedsUpdate(): bool
|
||||
{
|
||||
$currentIndexFields = $this->searchableFields;
|
||||
$expectedIndexFields = $this->getIndexFields();
|
||||
|
||||
return $currentIndexFields != $expectedIndexFields;
|
||||
}
|
||||
|
||||
private function getIndexFields(): array
|
||||
{
|
||||
$index = DB::select(
|
||||
"SHOW INDEX FROM $this->tableName WHERE Key_name = ?",
|
||||
[$this->indexName],
|
||||
);
|
||||
|
||||
$indexFields = [];
|
||||
|
||||
foreach ($index as $idx) {
|
||||
$indexFields[] = $idx->Column_name;
|
||||
}
|
||||
|
||||
return $indexFields;
|
||||
}
|
||||
|
||||
private function dropIndex()
|
||||
{
|
||||
if ($this->indexAlreadyExists) {
|
||||
DB::statement(
|
||||
"ALTER TABLE $this->tableName DROP INDEX $this->indexName",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
136
common/Search/Drivers/Mysql/MysqlSearchEngine.php
Executable file
136
common/Search/Drivers/Mysql/MysqlSearchEngine.php
Executable file
@@ -0,0 +1,136 @@
|
||||
<?php namespace Common\Search\Drivers\Mysql;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\LazyCollection;
|
||||
use Laravel\Scout\Builder;
|
||||
use Laravel\Scout\Engines\Engine;
|
||||
|
||||
class MysqlSearchEngine extends Engine
|
||||
{
|
||||
/**
|
||||
* Update the given model in the index.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Collection $models
|
||||
*/
|
||||
public function update($models): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given model from the index.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Collection $models
|
||||
*/
|
||||
public function delete($models): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function search(Builder $builder)
|
||||
{
|
||||
return $this->performSearch($builder, ['limit' => $builder->limit]);
|
||||
}
|
||||
|
||||
public function paginate(Builder $builder, $perPage, $page): Collection
|
||||
{
|
||||
return $this->performSearch($builder, [
|
||||
'limit' => $perPage,
|
||||
'offset' => $perPage * $page - $perPage,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function performSearch(
|
||||
Builder $builder,
|
||||
array $options = []
|
||||
): Collection {
|
||||
if ($builder->callback) {
|
||||
return call_user_func(
|
||||
$builder->callback,
|
||||
null,
|
||||
$builder->query,
|
||||
$options,
|
||||
);
|
||||
}
|
||||
|
||||
$query = $builder->model->mysqlSearch($builder->query);
|
||||
|
||||
if (!empty($builder->orders)) {
|
||||
foreach ($builder->orders as $order) {
|
||||
$query->orderBy(
|
||||
Arr::get($order, 'column'),
|
||||
Arr::get($order, 'direction'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($options['limit'])) {
|
||||
$query = $query->take($options['limit']);
|
||||
}
|
||||
if (isset($options['offset'])) {
|
||||
$query = $query->skip($options['offset']);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluck and return the primary keys of the given results.
|
||||
*
|
||||
* @param mixed $results
|
||||
* @return Collection
|
||||
*/
|
||||
public function mapIds($results)
|
||||
{
|
||||
return $results->pluck('id')->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the given results to instances of the given model.
|
||||
*
|
||||
* @param Builder $builder
|
||||
* @param mixed $results
|
||||
* @param Model $model
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public function map(Builder $builder, $results, $model)
|
||||
{
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total count from a raw result returned by the engine.
|
||||
*
|
||||
* @param mixed $results
|
||||
* @return int
|
||||
*/
|
||||
public function getTotalCount($results)
|
||||
{
|
||||
return count($results);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function flush($model)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function lazyMap(Builder $builder, $results, $model)
|
||||
{
|
||||
return LazyCollection::make($results);
|
||||
}
|
||||
|
||||
public function createIndex($name, array $options = [])
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function deleteIndex($name)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
126
common/Search/ImportRecordsIntoScout.php
Executable file
126
common/Search/ImportRecordsIntoScout.php
Executable file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Search;
|
||||
|
||||
use Algolia\AlgoliaSearch\Config\SearchConfig;
|
||||
use Algolia\AlgoliaSearch\SearchClient as Algolia;
|
||||
use App\Models\User;
|
||||
use Common\Search\Drivers\Mysql\MysqlFullTextIndexer;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Laravel\Scout\Console\ImportCommand;
|
||||
use Meilisearch\Client as MeilisearchClient;
|
||||
|
||||
class ImportRecordsIntoScout
|
||||
{
|
||||
public function execute(
|
||||
string $modelToImport = '*',
|
||||
string $driver = null,
|
||||
): void {
|
||||
@set_time_limit(0);
|
||||
@ini_set('memory_limit', '200M');
|
||||
|
||||
if ($selectedDriver = $driver) {
|
||||
config()->set('scout.driver', $selectedDriver);
|
||||
}
|
||||
$driver = config('scout.driver');
|
||||
|
||||
$models =
|
||||
$modelToImport === '*'
|
||||
? self::getSearchableModels()
|
||||
: [$modelToImport];
|
||||
|
||||
if ($driver === 'mysql') {
|
||||
foreach ($models as $model) {
|
||||
app(MysqlFullTextIndexer::class)->createOrUpdateIndex($model);
|
||||
}
|
||||
} elseif ($driver === 'meilisearch') {
|
||||
$this->configureMeilisearchIndices($models);
|
||||
} elseif ($driver === 'algolia') {
|
||||
$this->configureAlgoliaIndices($models);
|
||||
}
|
||||
|
||||
$this->importUsingDefaultScoutCommand($models);
|
||||
}
|
||||
|
||||
public static function getSearchableModels(): array
|
||||
{
|
||||
$appSearchableModels = config('searchable_models');
|
||||
$commonSearchableModels = [User::class];
|
||||
|
||||
return array_merge($appSearchableModels ?? [], $commonSearchableModels);
|
||||
}
|
||||
|
||||
private function importUsingDefaultScoutCommand(array $models): void
|
||||
{
|
||||
Artisan::registerCommand(app(ImportCommand::class));
|
||||
foreach ($models as $model) {
|
||||
$model = addslashes($model);
|
||||
Artisan::call("scout:import \"$model\"");
|
||||
}
|
||||
}
|
||||
|
||||
private function configureAlgoliaIndices(array $models): void
|
||||
{
|
||||
$config = SearchConfig::create(
|
||||
config('scout.algolia.id'),
|
||||
config('scout.algolia.secret'),
|
||||
);
|
||||
|
||||
$algolia = Algolia::createWithConfig($config);
|
||||
foreach ($models as $model) {
|
||||
$filterableFields = $model::filterableFields();
|
||||
|
||||
// keep ID searchable as there are issues with scout otherwise
|
||||
if (($key = array_search('id', $filterableFields)) !== false) {
|
||||
unset($filterableFields[$key]);
|
||||
}
|
||||
|
||||
$model = new $model();
|
||||
$indexName = $model->searchableAs();
|
||||
$algolia->initIndex($indexName)->setSettings([
|
||||
'attributesForFaceting' => array_values(
|
||||
array_map(
|
||||
fn($field) => "filterOnly($field)",
|
||||
$filterableFields,
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function configureMeilisearchIndices(array $models): void
|
||||
{
|
||||
$client = app(MeilisearchClient::class);
|
||||
|
||||
foreach ($models as $modelName) {
|
||||
$model = new $modelName();
|
||||
$indexName = $model->searchableAs();
|
||||
$index = $client->index($indexName);
|
||||
|
||||
if ($modelConfig = config("search.meilisearch.$modelName")) {
|
||||
$index->updateSettings($modelConfig);
|
||||
}
|
||||
|
||||
$searchableFields = array_merge(
|
||||
['id'],
|
||||
$model->getSearchableKeys(),
|
||||
);
|
||||
$displayedFields = $searchableFields;
|
||||
try {
|
||||
$client->index($indexName)->delete();
|
||||
} catch (Exception $e) {
|
||||
//
|
||||
}
|
||||
$client
|
||||
->index($indexName)
|
||||
->updateSearchableAttributes($searchableFields);
|
||||
$client
|
||||
->index($indexName)
|
||||
->updateFilterableAttributes($model::filterableFields());
|
||||
$client
|
||||
->index($indexName)
|
||||
->updateDisplayedAttributes($displayedFields);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user