From fb7160eb33a85f3628383a4fa31a90405d09626a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Tue, 2 Dec 2025 20:24:57 +0100 Subject: [PATCH] fixed search --- app/Models/Person/Person.php | 60 ++++++++++++- ...ll_name_search_columns_to_person_table.php | 90 +++++++++++++++++++ tests/Feature/PersonSearchTest.php | 18 ++++ 3 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 database/migrations/2025_12_02_000000_add_full_name_search_columns_to_person_table.php create mode 100644 tests/Feature/PersonSearchTest.php diff --git a/app/Models/Person/Person.php b/app/Models/Person/Person.php index ef3f74d..2633526 100644 --- a/app/Models/Person/Person.php +++ b/app/Models/Person/Person.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Str; use Laravel\Sanctum\HasApiTokens; +use Laravel\Scout\Attributes\SearchUsingFullText; use Laravel\Scout\Searchable; class Person extends Model @@ -64,6 +65,14 @@ protected static function booted() $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 @@ -71,16 +80,20 @@ protected function makeAllSearchableUsing(Builder $query): Builder return $query->with(['addresses', 'phones', 'emails']); } + #[SearchUsingFullText(['full_name_search'], ['config' => 'simple'])] public function toSearchableArray(): array { - return [ - 'first_name' => '', - 'last_name' => '', - 'full_name' => '', + $columns = [ + 'first_name' => (string) $this->first_name, + 'last_name' => (string) $this->last_name, + 'full_name' => (string) $this->full_name, 'person_addresses.address' => '', 'person_phones.nu' => '', 'emails.value' => '', + 'full_name_search' => (string) $this->full_name_search, ]; + + return $columns; } public function phones(): HasMany @@ -144,4 +157,43 @@ protected static function generateUniqueNu(): string 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(); + } } diff --git a/database/migrations/2025_12_02_000000_add_full_name_search_columns_to_person_table.php b/database/migrations/2025_12_02_000000_add_full_name_search_columns_to_person_table.php new file mode 100644 index 0000000..4982a9e --- /dev/null +++ b/database/migrations/2025_12_02_000000_add_full_name_search_columns_to_person_table.php @@ -0,0 +1,90 @@ +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'; + } +}; diff --git a/tests/Feature/PersonSearchTest.php b/tests/Feature/PersonSearchTest.php new file mode 100644 index 0000000..529f8ba --- /dev/null +++ b/tests/Feature/PersonSearchTest.php @@ -0,0 +1,18 @@ +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(); +});