Search the public site through the API
Full-text on Postgres, stemmed in the language searched; word matching elsewhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
201
app/Support/Search/SiteSearch.php
Normal file
201
app/Support/Search/SiteSearch.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Search;
|
||||
|
||||
use App\Models\Call;
|
||||
use App\Models\Member;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\Technology;
|
||||
use App\Support\SiteUrls;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Searching what the public site shows, in one language.
|
||||
*
|
||||
* On Postgres it is full-text search: words are stemmed in the language being
|
||||
* searched, so "proyectos" finds "proyecto", and results are ranked with the
|
||||
* title weighing more than the body. Anywhere else — SQLite in the test suite,
|
||||
* a laptop without Postgres — it falls back to matching the words as written,
|
||||
* which finds less and ranks crudely but answers the same shape.
|
||||
*
|
||||
* Only what is published: a search is a way of reaching the site's pages, and
|
||||
* a draft has none.
|
||||
*/
|
||||
final class SiteSearch
|
||||
{
|
||||
/**
|
||||
* Postgres text search configuration per language. Catalan has no stemmer
|
||||
* in a stock Postgres, so it is matched word for word.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected const DICTIONARIES = ['es' => 'spanish', 'en' => 'english'];
|
||||
|
||||
/**
|
||||
* Type => what is searched in it: the title field, the fields with
|
||||
* weight B, and whether those fields are translatable.
|
||||
*
|
||||
* @var array<string, array{model: class-string<Model>, title: string, translatedTitle: bool, text: array<int, string>}>
|
||||
*/
|
||||
protected const SOURCES = [
|
||||
'page' => ['model' => Page::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['excerpt', 'body']],
|
||||
'project' => ['model' => Project::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['tagline', 'description']],
|
||||
'service' => ['model' => Service::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['tagline', 'description']],
|
||||
'call' => ['model' => Call::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['description']],
|
||||
'member' => ['model' => Member::class, 'title' => 'name', 'translatedTitle' => false, 'text' => ['headline', 'bio']],
|
||||
'technology' => ['model' => Technology::class, 'title' => 'name', 'translatedTitle' => false, 'text' => ['description']],
|
||||
];
|
||||
|
||||
public const TYPES = ['page', 'project', 'service', 'call', 'member', 'technology'];
|
||||
|
||||
/**
|
||||
* @param array<int, string> $types
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
public function search(string $query, string $locale, array $types = self::TYPES, int $limit = 20): Collection
|
||||
{
|
||||
$query = trim($query);
|
||||
|
||||
if (mb_strlen($query) < 2) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$postgres = DB::connection()->getDriverName() === 'pgsql';
|
||||
|
||||
return collect(self::SOURCES)
|
||||
->only($types)
|
||||
->flatMap(fn (array $source, string $type) => $this->searchIn($type, $source, $query, $locale, $postgres, $limit))
|
||||
->sortByDesc('score')
|
||||
->take($limit)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{model: class-string<Model>, title: string, translatedTitle: bool, text: array<int, string>} $source
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
protected function searchIn(string $type, array $source, string $query, string $locale, bool $postgres, int $limit): Collection
|
||||
{
|
||||
$builder = $this->visible($source['model']::query(), $source['model']);
|
||||
|
||||
$title = $source['translatedTitle'] ? $this->json($source['title'], $locale, $postgres) : $source['title'];
|
||||
$text = collect($source['text'])->map(fn (string $field) => $this->json($field, $locale, $postgres));
|
||||
|
||||
if ($postgres) {
|
||||
$dictionary = self::DICTIONARIES[$locale] ?? 'simple';
|
||||
// Tags out of rich text, so `<strong>` is not a word anyone finds.
|
||||
$body = $text->map(fn (string $sql) => "regexp_replace(coalesce({$sql}, ''), '<[^>]+>', ' ', 'g')")->implode(" || ' ' || ");
|
||||
$document = "setweight(to_tsvector('{$dictionary}', coalesce({$title}, '')), 'A') || setweight(to_tsvector('{$dictionary}', {$body}), 'B')";
|
||||
$tsquery = "websearch_to_tsquery('{$dictionary}', ?)";
|
||||
|
||||
$builder
|
||||
->select('*')
|
||||
->selectRaw("ts_rank({$document}, {$tsquery}) as search_score", [$query])
|
||||
->whereRaw("{$document} @@ {$tsquery}", [$query])
|
||||
->orderByDesc('search_score');
|
||||
} else {
|
||||
$words = collect(preg_split('/\s+/', $query))->filter()->map(fn ($w) => '%'.mb_strtolower($w).'%');
|
||||
|
||||
foreach ($words as $word) {
|
||||
$builder->where(function (Builder $q) use ($title, $text, $word): void {
|
||||
$q->whereRaw("lower({$title}) like ?", [$word]);
|
||||
|
||||
foreach ($text as $sql) {
|
||||
$q->orWhereRaw("lower({$sql}) like ?", [$word]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return $builder->limit($limit)->get()->map(fn (Model $record) => $this->result($type, $source, $record, $query, $locale));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{model: class-string<Model>, title: string, translatedTitle: bool, text: array<int, string>} $source
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function result(string $type, array $source, Model $record, string $query, string $locale): array
|
||||
{
|
||||
$title = $source['translatedTitle']
|
||||
? (string) $record->getTranslation($source['title'], $locale, false)
|
||||
: (string) $record->getAttribute($source['title']);
|
||||
|
||||
$text = collect($source['text'])
|
||||
->map(fn (string $field) => (string) $record->getTranslation($field, $locale, false))
|
||||
->implode(' ');
|
||||
|
||||
// Crude where Postgres did not rank: a title match counts more.
|
||||
$score = $record->getAttribute('search_score')
|
||||
?? (Str::contains(mb_strtolower($title), mb_strtolower($query)) ? 2.0 : 1.0);
|
||||
|
||||
return [
|
||||
'type' => $type,
|
||||
'id' => $record->getKey(),
|
||||
'title' => $title,
|
||||
'excerpt' => $this->excerpt($text, $query),
|
||||
'path' => $this->path($type, $record, $locale),
|
||||
'score' => round((float) $score, 4),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Published, open or public, whichever each kind calls it.
|
||||
*
|
||||
* @param class-string<Model> $model
|
||||
*/
|
||||
protected function visible(Builder $query, string $model): Builder
|
||||
{
|
||||
return match ($model) {
|
||||
Call::class => $query->open(),
|
||||
Member::class => $query->public(),
|
||||
// A technology is shown on the site once something uses it.
|
||||
Technology::class => $query->whereHas('projects', fn (Builder $q) => $q->published()),
|
||||
default => $query->published(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The text in one language of a translatable JSON column.
|
||||
*/
|
||||
protected function json(string $field, string $locale, bool $postgres): string
|
||||
{
|
||||
return $postgres
|
||||
? "({$field}->>'{$locale}')"
|
||||
: "json_extract({$field}, '$.{$locale}')";
|
||||
}
|
||||
|
||||
/**
|
||||
* Some words either side of the first match, out of plain text.
|
||||
*/
|
||||
protected function excerpt(string $html, string $query): string
|
||||
{
|
||||
$text = trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($html))));
|
||||
$first = collect(preg_split('/\s+/', $query))->filter()->first() ?? $query;
|
||||
$at = mb_stripos($text, (string) $first);
|
||||
|
||||
if ($at === false || $at < 60) {
|
||||
return Str::limit($text, 180);
|
||||
}
|
||||
|
||||
return '…'.Str::limit(mb_substr($text, $at - 60), 180);
|
||||
}
|
||||
|
||||
protected function path(string $type, Model $record, string $locale): ?string
|
||||
{
|
||||
return match ($type) {
|
||||
'member' => SiteUrls::path('member', $locale, (string) $record->slug),
|
||||
// The site gives technologies pages, but this side has no address
|
||||
// for them: the slug is enough to build one.
|
||||
'technology' => null,
|
||||
default => SiteUrls::alternates($record, $type)[$locale] ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user