fixed search

This commit is contained in:
Simon Pocrnjič 2025-12-02 20:24:57 +01:00
parent 44f9f8f9fa
commit fb7160eb33
3 changed files with 164 additions and 4 deletions

View File

@ -12,6 +12,7 @@
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
use Laravel\Scout\Attributes\SearchUsingFullText;
use Laravel\Scout\Searchable; use Laravel\Scout\Searchable;
class Person extends Model class Person extends Model
@ -64,6 +65,14 @@ protected static function booted()
$person->nu = static::generateUniqueNu(); $person->nu = static::generateUniqueNu();
} }
}); });
static::saving(function (Person $person) {
$person->full_name_search = static::buildFullNameSearchPayload(
$person->first_name,
$person->last_name,
$person->full_name
);
});
} }
protected function makeAllSearchableUsing(Builder $query): Builder protected function makeAllSearchableUsing(Builder $query): Builder
@ -71,16 +80,20 @@ protected function makeAllSearchableUsing(Builder $query): Builder
return $query->with(['addresses', 'phones', 'emails']); return $query->with(['addresses', 'phones', 'emails']);
} }
#[SearchUsingFullText(['full_name_search'], ['config' => 'simple'])]
public function toSearchableArray(): array public function toSearchableArray(): array
{ {
return [ $columns = [
'first_name' => '', 'first_name' => (string) $this->first_name,
'last_name' => '', 'last_name' => (string) $this->last_name,
'full_name' => '', 'full_name' => (string) $this->full_name,
'person_addresses.address' => '', 'person_addresses.address' => '',
'person_phones.nu' => '', 'person_phones.nu' => '',
'emails.value' => '', 'emails.value' => '',
'full_name_search' => (string) $this->full_name_search,
]; ];
return $columns;
} }
public function phones(): HasMany public function phones(): HasMany
@ -144,4 +157,43 @@ protected static function generateUniqueNu(): string
return $nu; return $nu;
} }
protected static function buildFullNameSearchPayload(?string $firstName, ?string $lastName, ?string $fullName): string
{
$segments = collect([
static::joinNameParts($firstName, $lastName),
static::joinNameParts($lastName, $firstName),
$fullName,
])->filter();
if ($segments->isEmpty()) {
return '';
}
return $segments
->map(fn (string $segment): string => static::normalizeSegment($segment))
->filter()
->unique()
->implode(' ');
}
protected static function joinNameParts(?string $first, ?string $second): ?string
{
$parts = collect([$first, $second])->filter(fn ($value) => filled($value));
if ($parts->isEmpty()) {
return null;
}
return $parts->implode(' ');
}
protected static function normalizeSegment(?string $value): ?string
{
if (blank($value)) {
return null;
}
return (string) Str::of($value)->squish()->lower();
}
} }

View File

@ -0,0 +1,90 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('person', function (Blueprint $table) {
$table->text('full_name_search')->nullable();
});
$this->backfillSearchColumn();
if ($this->isPostgres()) {
DB::statement(<<<'SQL'
ALTER TABLE person
ADD COLUMN full_name_search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('simple', coalesce(full_name_search, '')))
STORED
SQL);
DB::statement('CREATE INDEX person_full_name_search_vector_idx ON person USING GIN (full_name_search_vector)');
}
}
public function down(): void
{
if ($this->isPostgres()) {
DB::statement('DROP INDEX IF EXISTS person_full_name_search_vector_idx');
DB::statement('ALTER TABLE person DROP COLUMN IF EXISTS full_name_search_vector');
}
Schema::table('person', function (Blueprint $table) {
$table->dropColumn('full_name_search');
});
}
private function backfillSearchColumn(): void
{
DB::table('person')
->select('id', 'first_name', 'last_name', 'full_name')
->lazyById()
->each(function ($row): void {
DB::table('person')
->where('id', $row->id)
->update(['full_name_search' => $this->buildSearchValue($row)]);
});
}
private function buildSearchValue(object $row): string
{
$segments = array_filter([
$this->joinParts($row->first_name ?? null, $row->last_name ?? null),
$this->joinParts($row->last_name ?? null, $row->first_name ?? null),
$row->full_name ?? null,
]);
if (empty($segments)) {
return '';
}
$normalized = array_unique(array_map(function (string $value): string {
$collapsed = preg_replace('/\s+/u', ' ', trim($value)) ?: '';
return mb_strtolower($collapsed);
}, $segments));
return trim(implode(' ', array_filter($normalized)));
}
private function joinParts(?string $first, ?string $second): ?string
{
$parts = array_filter([$first, $second], fn ($part) => filled($part));
if (empty($parts)) {
return null;
}
return trim(implode(' ', $parts));
}
private function isPostgres(): bool
{
return DB::connection()->getDriverName() === 'pgsql';
}
};

View File

@ -0,0 +1,18 @@
<?php
use App\Models\Person\Person;
it('finds a person when the query uses the inverted name order', function (): void {
config()->set('scout.driver', 'collection');
$person = Person::factory()->create([
'first_name' => 'John',
'last_name' => 'Dou',
'full_name' => 'Dou John',
]);
$results = Person::search('John Dou')->get();
expect($results)->toHaveCount(1)
->and($results->first()->is($person))->toBeTrue();
});