php-content/Services/ContentSearchService.php

495 lines
15 KiB
PHP
Raw Normal View History

2026-01-26 23:59:46 +00:00
<?php
declare(strict_types=1);
namespace Core\Mod\Content\Services;
2026-01-26 23:59:46 +00:00
use Carbon\Carbon;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Core\Mod\Content\Models\ContentItem;
2026-01-26 23:59:46 +00:00
/**
* Content Search Service
*
* Provides full-text search capabilities for content items with support for
* multiple backends:
* - Database (default): LIKE-based search with relevance scoring
* - Scout Database: Laravel Scout with database driver
* - Meilisearch: Laravel Scout with Meilisearch driver (optional)
*
* The service automatically uses the best available backend based on
* configuration and installed packages.
*/
class ContentSearchService
{
/**
* Search backend constants.
*/
public const BACKEND_DATABASE = 'database';
public const BACKEND_SCOUT_DATABASE = 'scout_database';
public const BACKEND_MEILISEARCH = 'meilisearch';
/**
* Minimum query length for search.
*/
protected int $minQueryLength = 2;
/**
* Maximum results per page.
*/
protected int $maxPerPage = 50;
/**
* Default results per page.
*/
protected int $defaultPerPage = 20;
/**
* Get the current search backend.
*/
public function getBackend(): string
{
$configured = config('content.search.backend', self::BACKEND_DATABASE);
// Validate Meilisearch is available if configured
if ($configured === self::BACKEND_MEILISEARCH) {
if (! $this->isMeilisearchAvailable()) {
return self::BACKEND_DATABASE;
}
}
// Validate Scout is available if configured
if ($configured === self::BACKEND_SCOUT_DATABASE) {
if (! $this->isScoutAvailable()) {
return self::BACKEND_DATABASE;
}
}
return $configured;
}
/**
* Check if Laravel Scout is available.
*/
public function isScoutAvailable(): bool
{
return class_exists(\Laravel\Scout\Searchable::class);
}
/**
* Check if Meilisearch is available and configured.
*/
public function isMeilisearchAvailable(): bool
{
if (! class_exists(\Meilisearch\Client::class)) {
return false;
}
$host = config('scout.meilisearch.host');
return ! empty($host);
}
/**
* Search content items.
*
* @param string $query Search query
* @param array{
* workspace_id?: int,
* type?: string,
* status?: string|array,
* category?: string,
* tag?: string,
* content_type?: string,
* date_from?: string|Carbon,
* date_to?: string|Carbon,
* per_page?: int,
* page?: int,
* } $filters
* @return LengthAwarePaginator<ContentItem>
*/
public function search(string $query, array $filters = []): LengthAwarePaginator
{
$query = trim($query);
$perPage = min($filters['per_page'] ?? $this->defaultPerPage, $this->maxPerPage);
$page = max($filters['page'] ?? 1, 1);
// For very short queries, use database search
if (strlen($query) < $this->minQueryLength) {
return $this->emptyPaginatedResult($perPage, $page);
}
$backend = $this->getBackend();
return match ($backend) {
self::BACKEND_MEILISEARCH,
self::BACKEND_SCOUT_DATABASE => $this->searchWithScout($query, $filters, $perPage, $page),
default => $this->searchWithDatabase($query, $filters, $perPage, $page),
};
}
/**
* Search using database LIKE queries with relevance scoring.
*
* @return LengthAwarePaginator<ContentItem>
*/
protected function searchWithDatabase(string $query, array $filters, int $perPage, int $page): LengthAwarePaginator
{
$baseQuery = $this->buildBaseQuery($filters);
$searchTerms = $this->tokeniseQuery($query);
// Build search conditions
$baseQuery->where(function (Builder $q) use ($query, $searchTerms) {
// Exact phrase match in title
$q->where('title', 'like', "%{$query}%");
// Individual term matches
foreach ($searchTerms as $term) {
if (strlen($term) >= $this->minQueryLength) {
$q->orWhere('title', 'like', "%{$term}%")
->orWhere('excerpt', 'like', "%{$term}%")
->orWhere('content_html', 'like', "%{$term}%")
->orWhere('content_markdown', 'like', "%{$term}%")
->orWhere('slug', 'like', "%{$term}%");
}
}
});
// Get all matching results for scoring
$allResults = $baseQuery->get();
// Calculate relevance scores and sort
$scored = $this->scoreResults($allResults, $query, $searchTerms);
// Manual pagination of scored results
$total = $scored->count();
$offset = ($page - 1) * $perPage;
$items = $scored->slice($offset, $perPage)->values();
// Convert to paginator
return new \Illuminate\Pagination\LengthAwarePaginator(
$items,
$total,
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()]
);
}
/**
* Search using Laravel Scout.
*
* @return LengthAwarePaginator<ContentItem>
*/
protected function searchWithScout(string $query, array $filters, int $perPage, int $page): LengthAwarePaginator
{
// Check if ContentItem uses Searchable trait
if (! in_array(\Laravel\Scout\Searchable::class, class_uses_recursive(ContentItem::class))) {
// Fall back to database search
return $this->searchWithDatabase($query, $filters, $perPage, $page);
}
$searchBuilder = ContentItem::search($query);
// Apply workspace filter
if (isset($filters['workspace_id'])) {
$searchBuilder->where('workspace_id', $filters['workspace_id']);
}
// Apply content type filter (native types)
$searchBuilder->where('content_type', 'native');
// Apply filters via query callback for Scout database driver
$searchBuilder->query(function (Builder $builder) use ($filters) {
$this->applyFilters($builder, $filters);
});
return $searchBuilder->paginate($perPage, 'page', $page);
}
/**
* Get search suggestions based on partial query.
*
* @return Collection<int, array{title: string, slug: string, type: string}>
*/
public function suggest(string $query, int $workspaceId, int $limit = 10): Collection
{
$query = trim($query);
if (strlen($query) < $this->minQueryLength) {
return collect();
}
return ContentItem::forWorkspace($workspaceId)
->native()
->where(function (Builder $q) use ($query) {
$q->where('title', 'like', "{$query}%")
->orWhere('title', 'like', "% {$query}%")
->orWhere('slug', 'like', "{$query}%");
})
->select(['id', 'title', 'slug', 'type', 'status'])
->orderByRaw('CASE WHEN title LIKE ? THEN 0 ELSE 1 END', ["{$query}%"])
->orderBy('updated_at', 'desc')
->limit($limit)
->get()
->map(fn (ContentItem $item) => [
'id' => $item->id,
'title' => $item->title,
'slug' => $item->slug,
'type' => $item->type,
'status' => $item->status,
]);
}
/**
* Build the base query with workspace and content type scope.
*/
protected function buildBaseQuery(array $filters): Builder
{
$query = ContentItem::query()->with(['author', 'taxonomies']);
// Always scope to native content types
$query->native();
// Apply all filters
$this->applyFilters($query, $filters);
return $query;
}
/**
* Apply filters to a query builder.
*/
protected function applyFilters(Builder $query, array $filters): void
{
// Workspace filter
if (isset($filters['workspace_id'])) {
$query->forWorkspace($filters['workspace_id']);
}
// Content type (post/page)
if (! empty($filters['type'])) {
$query->where('type', $filters['type']);
}
// Status filter
if (! empty($filters['status'])) {
if (is_array($filters['status'])) {
$query->whereIn('status', $filters['status']);
} else {
$query->where('status', $filters['status']);
}
}
// Category filter
if (! empty($filters['category'])) {
$query->whereHas('categories', function (Builder $q) use ($filters) {
$q->where('slug', $filters['category']);
});
}
// Tag filter
if (! empty($filters['tag'])) {
$query->whereHas('tags', function (Builder $q) use ($filters) {
$q->where('slug', $filters['tag']);
});
}
// Content source type filter
if (! empty($filters['content_type'])) {
$query->where('content_type', $filters['content_type']);
}
// Date range filters
if (! empty($filters['date_from'])) {
$dateFrom = $filters['date_from'] instanceof Carbon
? $filters['date_from']
: Carbon::parse($filters['date_from']);
$query->where('created_at', '>=', $dateFrom->startOfDay());
}
if (! empty($filters['date_to'])) {
$dateTo = $filters['date_to'] instanceof Carbon
? $filters['date_to']
: Carbon::parse($filters['date_to']);
$query->where('created_at', '<=', $dateTo->endOfDay());
}
}
/**
* Tokenise a search query into individual terms.
*
* @return array<string>
*/
protected function tokeniseQuery(string $query): array
{
// Split on whitespace and filter empty/short terms
return array_values(array_filter(
preg_split('/\s+/', $query) ?: [],
fn ($term) => strlen($term) >= $this->minQueryLength
));
}
/**
* Calculate relevance scores for search results.
*
* @param Collection<int, ContentItem> $items
* @param array<string> $searchTerms
* @return Collection<int, ContentItem>
*/
protected function scoreResults(Collection $items, string $query, array $searchTerms): Collection
{
$queryLower = strtolower($query);
return $items
->map(function (ContentItem $item) use ($queryLower, $searchTerms) {
$score = $this->calculateRelevanceScore($item, $queryLower, $searchTerms);
$item->setAttribute('relevance_score', $score);
return $item;
})
->sortByDesc('relevance_score');
}
/**
* Calculate relevance score for a single content item.
*/
protected function calculateRelevanceScore(ContentItem $item, string $queryLower, array $searchTerms): int
{
$score = 0;
$titleLower = strtolower($item->title ?? '');
$slugLower = strtolower($item->slug ?? '');
$excerptLower = strtolower($item->excerpt ?? '');
$contentLower = strtolower(strip_tags($item->content_html ?? $item->content_markdown ?? ''));
// Exact phrase matches (highest weight)
if ($titleLower === $queryLower) {
$score += 200; // Exact title match
} elseif (str_starts_with($titleLower, $queryLower)) {
$score += 150; // Title starts with query
} elseif (str_contains($titleLower, $queryLower)) {
$score += 100; // Title contains query
}
if (str_contains($slugLower, $queryLower)) {
$score += 50; // Slug contains query
}
// Individual term matches
foreach ($searchTerms as $term) {
$termLower = strtolower($term);
if (str_contains($titleLower, $termLower)) {
$score += 30;
}
if (str_contains($slugLower, $termLower)) {
$score += 20;
}
if (str_contains($excerptLower, $termLower)) {
$score += 15;
}
if (str_contains($contentLower, $termLower)) {
$score += 5;
}
}
// Status boost (published content should rank higher)
if ($item->status === 'publish') {
$score += 10;
}
// Recency boost (content updated within 30 days)
if ($item->updated_at && $item->updated_at->diffInDays(now()) < 30) {
$score += 5;
}
return $score;
}
/**
* Create an empty paginated result.
*
* @return LengthAwarePaginator<ContentItem>
*/
protected function emptyPaginatedResult(int $perPage, int $page): LengthAwarePaginator
{
return new \Illuminate\Pagination\LengthAwarePaginator(
collect(),
0,
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()]
);
}
/**
* Re-index all content items for Scout.
*
* Only applicable when using Scout backend.
*/
public function reindex(?Workspace $workspace = null): int
{
if (! $this->isScoutAvailable()) {
return 0;
}
if (! in_array(\Laravel\Scout\Searchable::class, class_uses_recursive(ContentItem::class))) {
return 0;
}
$query = ContentItem::native();
if ($workspace) {
$query->forWorkspace($workspace->id);
}
$count = 0;
$query->chunk(100, function ($items) use (&$count) {
$items->searchable();
$count += $items->count();
});
return $count;
}
/**
* Format search results for API response.
*
* @param LengthAwarePaginator<ContentItem> $results
*/
public function formatForApi(LengthAwarePaginator $results): array
{
return [
'data' => $results->map(fn (ContentItem $item) => [
'id' => $item->id,
'slug' => $item->slug,
'title' => $item->title,
'type' => $item->type,
'status' => $item->status,
'content_type' => $item->content_type?->value,
'excerpt' => Str::limit($item->excerpt ?? strip_tags($item->content_html ?? ''), 200),
'author' => $item->author?->name,
'categories' => $item->categories->pluck('name')->all(),
'tags' => $item->tags->pluck('name')->all(),
'relevance_score' => $item->getAttribute('relevance_score'),
'created_at' => $item->created_at?->toIso8601String(),
'updated_at' => $item->updated_at?->toIso8601String(),
])->all(),
'meta' => [
'current_page' => $results->currentPage(),
'last_page' => $results->lastPage(),
'per_page' => $results->perPage(),
'total' => $results->total(),
'backend' => $this->getBackend(),
],
];
}
}