Full-text on Postgres, stemmed in the language searched; word matching elsewhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
202 lines
8.1 KiB
PHP
202 lines
8.1 KiB
PHP
<?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,
|
|
};
|
|
}
|
|
}
|