Skip to content

Instantly share code, notes, and snippets.

@codemonkey76
Created April 30, 2025 04:32
Show Gist options
  • Save codemonkey76/00b79ff00eddd4d3eacdbf46efc9492e to your computer and use it in GitHub Desktop.
Save codemonkey76/00b79ff00eddd4d3eacdbf46efc9492e to your computer and use it in GitHub Desktop.
class ModelBrowseService
{
/**
* @param class-string<Model&Searchable> $modelClass
*/
public function browse(
Request $request,
string $modelClass,
?Collection $hits = null,
array $filters = []
): array {
$search = $request->input('search', '');
$page = max((int) $request->input('page', 1), 1);
$perPage = (int) $request->input('per_page', 15);
$highlightFields = (method_exists($modelClass, 'highlightFields') ? $modelClass::highlightFields() : ['*']);
// Let meilisearch handle the pagination
$searchResult = $this->searchModelPaginated($modelClass, $search, $highlightFields, $filters, $page, $perPage);
$hits = $searchResult['hits'];
$total = $searchResult['estimatedTotalHits'];
$ids = $hits->pluck('objectID')->map(function ($objectId) {
if (is_string($objectId) && str_contains($objectId, '::')) {
[$model, $id] = explode('::', $objectId, 2);
return (int) $id;
}
return (int) $objectId;
})->toArray();
if (empty($ids)) {
return $this->emptyResponse($modelClass, $perPage);
}
$highlightMap = $this->mapHighlights($hits);
$query = $modelClass::withoutGlobalScopes()->whereIn('id', $ids);
if (method_exists($modelClass, 'withBrowseQuery')) {
$query = $modelClass::withBrowseQuery($query);
}
foreach ($filters as $key => $value) {
$query->where($key, $value);
}
$models = $query->get()->each(function ($model) use ($highlightMap) {
foreach ($highlightMap[$model->id] ?? [] as $field => $highlighted) {
$model->setAttribute("highlighted_{$field}", $highlighted);
}
});
// Ensure correct order
$sorted = collect($ids)->map(fn($id) => $models->firstWhere('id', $id))->filter();
$paginator = new \Illuminate\Pagination\LengthAwarePaginator(
$sorted, $total, $perPage, $page, ['path' => request()->url(), 'query' => request()->query()]
);
$resourceClass = $this->guessResourceClass($modelClass);
return [
'data' => $resourceClass ? $resourceClass::collection($paginator) : $paginator->items(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'from' => $paginator->firstItem(),
'to' => $paginator->lastItem(),
]
];
}
/**
* @param class-string<Model&Searchable> $modelClass
*/
public function searchModelPaginated(
string $modelClass,
string $search,
array $highlightFields,
array $filters = [],
int $page = 1,
int $perPage = 15
): array {
try {
$result = $modelClass::search($search ?: '',
function ($engine, $query, $options) use ($highlightFields, $filters, $page, $perPage) {
$options['attributesToHighlight'] = implode(',', $highlightFields);
$options['hitsPerPage'] = $perPage;
$options['page'] = $page - 1;
if (!empty($filters)) {
$options['filters'] = collect($filters)
->map(function ($v, $k) {
if (is_array($v)) {
return '(' . collect($v)
->map(fn($item) => "{$k}:'" . addslashes((string)$item) . "'")
->implode(' OR ') . ')';
}
return "{$k}:'" . addslashes((string)$v) . "'";
})
->values()
->implode(' AND ');
}
return $engine->search($query, ['params' => http_build_query($options)]);
})->raw();
return [
'hits' => collect($result['hits'] ?? []),
'estimatedTotalHits' => $result['nbHits'] ?? 0,
];
} catch (ApiException $e) {
Log::error("Meilisearch error: {$e->getMessage()}", [
'model' => $modelClass,
'search' => $search,
'highlightFields' => $highlightFields,
]);
return [
'hits' => collect(),
'estimatedTotalHits' => 0,
];
} catch (\Exception $e) {
Log::error("Error searching model: {$e->getMessage()}", [
'model' => $modelClass,
'search' => $search,
'highlightFields' => $highlightFields,
]);
return [
'hits' => collect(),
'estimatedTotalHits' => 0,
];
}
}
private function emptyResponse(string $modelClass, int $perPage): array
{
$resourceClass = $this->guessResourceClass($modelClass);
return [
'data' => $resourceClass ? $resourceClass::collection([]) : [],
'meta' => [
'current_page' => 1,
'last_page' => 1,
'per_page' => $perPage,
'total' => 0,
'from' => 0,
'to' => 0,
]
];
}
public function mapHighlights(Collection $hits): Collection
{
return $hits->mapWithKeys(function ($hit) {
$id = (int) ($hit['id'] ?? $hit['objectID']);
return [$id => $hit['_formatted'] ?? []];
});
}
protected function paginateCollection(Collection $items, int $page, int $perPage): LengthAwarePaginator
{
$offsetItems = $items->forPage($page, $perPage);
return new \Illuminate\Pagination\LengthAwarePaginator(
$offsetItems,
$items->count(),
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()]
);
}
/**
* @return class-string<JsonResource>|null
*/
private function guessResourceClass(string $modelClass): ?string
{
$baseName = class_basename($modelClass);
$resourceClass = "App\\Http\\Resources\\{$baseName}Resource";
return class_exists($resourceClass) ? $resourceClass : null;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment