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:
2026-10-01 08:53:34 +02:00
parent 68c5bd4274
commit 6b3517743a
8 changed files with 417 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\PublicApi\V1;
use App\Support\Search\SiteSearch;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* GET /public/v1/search?q=…
*
* What the site shows that matches some words, in the request's language,
* best match first.
*/
class SearchController extends PublicController
{
public function __invoke(Request $request, SiteSearch $search): JsonResponse
{
$data = $request->validate([
'q' => ['required', 'string', 'min:2', 'max:120'],
'type' => ['sometimes', 'array'],
'type.*' => [Rule::in(SiteSearch::TYPES)],
'limit' => ['sometimes', 'integer', 'min:1', 'max:50'],
]);
$results = $search->search(
$data['q'],
app()->getLocale(),
$data['type'] ?? SiteSearch::TYPES,
(int) ($data['limit'] ?? 20),
);
return response()->json([
'data' => $results,
'meta' => ['query' => $data['q'], 'locale' => app()->getLocale(), 'count' => $results->count()],
]);
}
}

View 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,
};
}
}