Merge branch 'master' into Development
This commit is contained in:
commit
f5530edcea
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
};
|
||||||
18
tests/Feature/PersonSearchTest.php
Normal file
18
tests/Feature/PersonSearchTest.php
Normal 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();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user