Skip to content

Commit 6f67918

Browse files
committed
fix: improve search and harden API services
Replace in-memory search filtering with PostgreSQL unaccent + ILIKE across all index views. Search now splits on words so "Jürgen Habermas" matches "Habermas, Jürgen" in the author field. Accent-insensitive matching handled at the database level instead of loading all rows into PHP. Scope trusted proxies to private networks instead of 0.0.0.0/0. Add logging to all API service catch blocks. Add missing indexes on comic_issues.comicvine_issue_id and episodes.show_id.
1 parent b0c5b1a commit 6f67918

10 files changed

Lines changed: 140 additions & 225 deletions

File tree

app/Livewire/Anime/AnimeIndex.php

Lines changed: 13 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
use App\Models\Anime;
99
use Illuminate\Support\Facades\Auth;
1010
use Illuminate\Support\Facades\DB;
11-
use Illuminate\Support\Str;
1211
use Livewire\Component;
1312
use Livewire\WithPagination;
1413

@@ -46,21 +45,17 @@ class AnimeIndex extends Component
4645
'viewMode' => ['except' => 'gallery'],
4746
];
4847

49-
private function normalizeForSearch(string $string): string
48+
private function applyAccentInsensitiveSearch($query, string $search, array $columns): void
5049
{
51-
return Str::ascii($string);
52-
}
50+
$words = preg_split('/\s+/', trim($search));
5351

54-
private function matchesSearch(?string $value, string $normalizedSearch): bool
55-
{
56-
if ($value === null) {
57-
return false;
52+
foreach ($words as $word) {
53+
$query->where(function ($q) use ($word, $columns) {
54+
foreach ($columns as $column) {
55+
$q->orWhereRaw('unaccent(COALESCE(' . $column . ", '')) ILIKE unaccent(?)", ['%' . $word . '%']);
56+
}
57+
});
5858
}
59-
60-
return str_contains(
61-
strtolower($this->normalizeForSearch($value)),
62-
strtolower($normalizedSearch)
63-
);
6459
}
6560

6661
public function updatingSearch(): void
@@ -133,13 +128,8 @@ public function updatedSelectAll(bool $value): void
133128
});
134129

135130
if ($this->search) {
136-
$normalizedSearch = $this->normalizeForSearch($this->search);
137-
$allAnime = $query->get();
138-
$this->selected = $allAnime->filter(function ($anime) use ($normalizedSearch) {
139-
return $this->matchesSearch($anime->title, $normalizedSearch)
140-
|| $this->matchesSearch($anime->original_title, $normalizedSearch)
141-
|| $this->matchesSearch($anime->studios, $normalizedSearch);
142-
})->pluck('id')->map(fn ($id) => (string) $id)->toArray();
131+
$this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'original_title', 'studios']);
132+
$this->selected = $query->pluck('id')->map(fn ($id) => (string) $id)->toArray();
143133
} else {
144134
$this->selected = $query->pluck('id')->map(fn ($id) => (string) $id)->toArray();
145135
}
@@ -196,39 +186,11 @@ public function render()
196186
}
197187

198188
if ($this->search) {
199-
$normalizedSearch = $this->normalizeForSearch($this->search);
200-
201-
$exactMatchIds = (clone $query)
202-
->where(function ($q) {
203-
$q->where('title', 'like', '%' . $this->search . '%')
204-
->orWhere('original_title', 'like', '%' . $this->search . '%')
205-
->orWhere('studios', 'like', '%' . $this->search . '%');
206-
})
207-
->pluck('id');
208-
209-
$allAnime = $query->get();
210-
$filteredIds = $allAnime->filter(function ($anime) use ($normalizedSearch) {
211-
return $this->matchesSearch($anime->title, $normalizedSearch)
212-
|| $this->matchesSearch($anime->original_title, $normalizedSearch)
213-
|| $this->matchesSearch($anime->studios, $normalizedSearch);
214-
})->pluck('id');
215-
216-
$matchingIds = $exactMatchIds->merge($filteredIds)->unique();
217-
218-
$searchQuery = Anime::query()
219-
->whereIn('id', $matchingIds);
220-
221-
if ($sortBy === 'date_watched') {
222-
$searchQuery->orderBy(DB::raw('COALESCE(date_watched, updated_at)'), $sortDir);
223-
} else {
224-
$searchQuery->orderBy($sortBy, $sortDir);
225-
}
226-
227-
$animeList = $searchQuery->paginate($perPage);
228-
} else {
229-
$animeList = $query->paginate($perPage);
189+
$this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'original_title', 'studios']);
230190
}
231191

192+
$animeList = $query->paginate($perPage);
193+
232194
$allMediaTypes = Anime::where('user_id', Auth::id())
233195
->whereNotNull('media_type')
234196
->distinct()

app/Livewire/Books/BookIndex.php

Lines changed: 13 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -8,35 +8,24 @@
88
use App\Models\Book;
99
use Illuminate\Support\Facades\Auth;
1010
use Illuminate\Support\Facades\DB;
11-
use Illuminate\Support\Str;
1211
use Livewire\Component;
1312
use Livewire\WithPagination;
1413

1514
class BookIndex extends Component
1615
{
1716
use WithPagination;
1817

19-
/**
20-
* Normalize a string by removing diacritics/accents for comparison.
21-
*/
22-
private function normalizeForSearch(string $string): string
18+
private function applyAccentInsensitiveSearch($query, string $search, array $columns): void
2319
{
24-
return Str::ascii($string);
25-
}
20+
$words = preg_split('/\s+/', trim($search));
2621

27-
/**
28-
* Check if a value matches the search term (accent-insensitive).
29-
*/
30-
private function matchesSearch(?string $value, string $normalizedSearch): bool
31-
{
32-
if ($value === null) {
33-
return false;
22+
foreach ($words as $word) {
23+
$query->where(function ($q) use ($word, $columns) {
24+
foreach ($columns as $column) {
25+
$q->orWhereRaw('unaccent(COALESCE(' . $column . ", '')) ILIKE unaccent(?)", ['%' . $word . '%']);
26+
}
27+
});
3428
}
35-
36-
return str_contains(
37-
strtolower($this->normalizeForSearch($value)),
38-
strtolower($normalizedSearch)
39-
);
4029
}
4130

4231
public string $search = '';
@@ -164,12 +153,8 @@ public function updatedSelectAll(bool $value): void
164153
});
165154

166155
if ($this->search) {
167-
$normalizedSearch = $this->normalizeForSearch($this->search);
168-
$allBooks = $query->get();
169-
$this->selected = $allBooks->filter(function ($book) use ($normalizedSearch) {
170-
return $this->matchesSearch($book->title, $normalizedSearch)
171-
|| $this->matchesSearch($book->author, $normalizedSearch);
172-
})->pluck('id')->map(fn ($id) => (string) $id)->toArray();
156+
$this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'author']);
157+
$this->selected = $query->pluck('id')->map(fn ($id) => (string) $id)->toArray();
173158
} else {
174159
$this->selected = $query->pluck('id')->map(fn ($id) => (string) $id)->toArray();
175160
}
@@ -246,53 +231,12 @@ public function render()
246231
$query->orderBy($sortBy, $sortDir);
247232
}
248233

249-
// For search, use accent-insensitive PHP filtering
250234
if ($this->search) {
251-
$normalizedSearch = $this->normalizeForSearch($this->search);
252-
253-
// First try exact match in SQL for performance
254-
$exactMatchIds = (clone $query)
255-
->where(function ($q) {
256-
$q->where('title', 'like', '%'.$this->search.'%')
257-
->orWhere('author', 'like', '%'.$this->search.'%');
258-
})
259-
->pluck('id');
260-
261-
// Then get all books and filter with accent-insensitive comparison
262-
$allBooks = $query->get();
263-
$filteredIds = $allBooks->filter(function ($book) use ($normalizedSearch) {
264-
return $this->matchesSearch($book->title, $normalizedSearch)
265-
|| $this->matchesSearch($book->author, $normalizedSearch);
266-
})->pluck('id');
267-
268-
// Combine both result sets
269-
$matchingIds = $exactMatchIds->merge($filteredIds)->unique();
270-
271-
$searchQuery = Book::query()
272-
->whereIn('id', $matchingIds)
273-
->with('bookShelves');
274-
275-
if ($sortBy === 'page_count') {
276-
if ($sortDir === 'asc') {
277-
$searchQuery->orderByRaw('page_count IS NOT NULL')
278-
->orderByRaw('CASE WHEN page_count IS NULL THEN title END ASC')
279-
->orderBy('page_count', 'asc');
280-
} else {
281-
$searchQuery->orderByRaw('page_count IS NULL')
282-
->orderBy('page_count', 'desc')
283-
->orderByRaw('CASE WHEN page_count IS NULL THEN title END DESC');
284-
}
285-
} elseif ($sortBy === 'date_finished') {
286-
$searchQuery->orderBy(\Illuminate\Support\Facades\DB::raw('COALESCE(date_finished, updated_at)'), $sortDir);
287-
} else {
288-
$searchQuery->orderBy($sortBy, $sortDir);
289-
}
290-
291-
$books = $searchQuery->paginate($perPage);
292-
} else {
293-
$books = $query->paginate($perPage);
235+
$this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'author']);
294236
}
295237

238+
$books = $query->paginate($perPage);
239+
296240
return view('livewire.books.book-index', [
297241
'books' => $books,
298242
'statuses' => $this->getStatuses(),

app/Livewire/Comics/ComicIndex.php

Lines changed: 13 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,24 @@
88
use App\Models\Comic;
99
use Illuminate\Support\Facades\Auth;
1010
use Illuminate\Support\Facades\DB;
11-
use Illuminate\Support\Str;
1211
use Livewire\Component;
1312
use Livewire\WithPagination;
1413

1514
class ComicIndex extends Component
1615
{
1716
use WithPagination;
1817

19-
private function normalizeForSearch(string $string): string
18+
private function applyAccentInsensitiveSearch($query, string $search, array $columns): void
2019
{
21-
return Str::ascii($string);
22-
}
20+
$words = preg_split('/\s+/', trim($search));
2321

24-
private function matchesSearch(?string $value, string $normalizedSearch): bool
25-
{
26-
if ($value === null) {
27-
return false;
22+
foreach ($words as $word) {
23+
$query->where(function ($q) use ($word, $columns) {
24+
foreach ($columns as $column) {
25+
$q->orWhereRaw('unaccent(COALESCE(' . $column . ", '')) ILIKE unaccent(?)", ['%' . $word . '%']);
26+
}
27+
});
2828
}
29-
30-
return str_contains(
31-
strtolower($this->normalizeForSearch($value)),
32-
strtolower($normalizedSearch)
33-
);
3429
}
3530

3631
public string $search = '';
@@ -133,12 +128,8 @@ public function updatedSelectAll(bool $value): void
133128
});
134129

135130
if ($this->search) {
136-
$normalizedSearch = $this->normalizeForSearch($this->search);
137-
$allComics = $query->get();
138-
$this->selected = $allComics->filter(function ($comic) use ($normalizedSearch) {
139-
return $this->matchesSearch($comic->title, $normalizedSearch)
140-
|| $this->matchesSearch($comic->publisher, $normalizedSearch);
141-
})->pluck('id')->map(fn ($id) => (string) $id)->toArray();
131+
$this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'publisher']);
132+
$this->selected = $query->pluck('id')->map(fn ($id) => (string) $id)->toArray();
142133
} else {
143134
$this->selected = $query->pluck('id')->map(fn ($id) => (string) $id)->toArray();
144135
}
@@ -197,47 +188,11 @@ public function render()
197188
}
198189

199190
if ($this->search) {
200-
$normalizedSearch = $this->normalizeForSearch($this->search);
201-
202-
$exactMatchIds = (clone $query)
203-
->where(function ($q) {
204-
$q->where('title', 'like', '%'.$this->search.'%')
205-
->orWhere('publisher', 'like', '%'.$this->search.'%');
206-
})
207-
->pluck('id');
208-
209-
$allComics = $query->get();
210-
$filteredIds = $allComics->filter(function ($comic) use ($normalizedSearch) {
211-
return $this->matchesSearch($comic->title, $normalizedSearch)
212-
|| $this->matchesSearch($comic->publisher, $normalizedSearch);
213-
})->pluck('id');
214-
215-
$matchingIds = $exactMatchIds->merge($filteredIds)->unique();
216-
217-
$searchQuery = Comic::query()
218-
->whereIn('id', $matchingIds);
219-
220-
if (in_array($sortBy, ['issue_count', 'start_year'])) {
221-
if ($sortDir === 'asc') {
222-
$searchQuery->orderByRaw("{$sortBy} IS NOT NULL")
223-
->orderByRaw("CASE WHEN {$sortBy} IS NULL THEN title END ASC")
224-
->orderBy($sortBy, 'asc');
225-
} else {
226-
$searchQuery->orderByRaw("{$sortBy} IS NULL")
227-
->orderBy($sortBy, 'desc')
228-
->orderByRaw("CASE WHEN {$sortBy} IS NULL THEN title END DESC");
229-
}
230-
} elseif ($sortBy === 'date_finished') {
231-
$searchQuery->orderBy(DB::raw('COALESCE(date_finished, updated_at)'), $sortDir);
232-
} else {
233-
$searchQuery->orderBy($sortBy, $sortDir);
234-
}
235-
236-
$comics = $searchQuery->paginate($perPage);
237-
} else {
238-
$comics = $query->paginate($perPage);
191+
$this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'publisher']);
239192
}
240193

194+
$comics = $query->paginate($perPage);
195+
241196
$publishers = Comic::query()
242197
->where('user_id', Auth::id())
243198
->whereNotNull('publisher')

app/Livewire/Movies/MovieIndex.php

Lines changed: 11 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
use App\Models\Movie;
99
use Illuminate\Support\Facades\Auth;
1010
use Illuminate\Support\Facades\DB;
11-
use Illuminate\Support\Str;
1211
use Livewire\Component;
1312
use Livewire\WithPagination;
1413

@@ -51,21 +50,17 @@ class MovieIndex extends Component
5150
'viewMode' => ['except' => 'gallery'],
5251
];
5352

54-
private function normalizeForSearch(string $string): string
53+
private function applyAccentInsensitiveSearch($query, string $search, array $columns): void
5554
{
56-
return Str::ascii($string);
57-
}
55+
$words = preg_split('/\s+/', trim($search));
5856

59-
private function matchesSearch(?string $value, string $normalizedSearch): bool
60-
{
61-
if ($value === null) {
62-
return false;
57+
foreach ($words as $word) {
58+
$query->where(function ($q) use ($word, $columns) {
59+
foreach ($columns as $column) {
60+
$q->orWhereRaw('unaccent(COALESCE(' . $column . ", '')) ILIKE unaccent(?)", ['%' . $word . '%']);
61+
}
62+
});
6363
}
64-
65-
return str_contains(
66-
strtolower($this->normalizeForSearch($value)),
67-
strtolower($normalizedSearch)
68-
);
6964
}
7065

7166
public function updatingSearch(): void
@@ -143,13 +138,8 @@ public function updatedSelectAll(bool $value): void
143138
$this->applyTypeFilter($query);
144139

145140
if ($this->search) {
146-
$normalizedSearch = $this->normalizeForSearch($this->search);
147-
$allMovies = $query->get();
148-
$this->selected = $allMovies->filter(function ($movie) use ($normalizedSearch) {
149-
return $this->matchesSearch($movie->title, $normalizedSearch)
150-
|| $this->matchesSearch($movie->director, $normalizedSearch)
151-
|| $this->matchesSearch($movie->original_title, $normalizedSearch);
152-
})->pluck('id')->map(fn ($id) => (string) $id)->toArray();
141+
$this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'director', 'original_title']);
142+
$this->selected = $query->pluck('id')->map(fn ($id) => (string) $id)->toArray();
153143
} else {
154144
$this->selected = $query->pluck('id')->map(fn ($id) => (string) $id)->toArray();
155145
}
@@ -215,16 +205,7 @@ public function render()
215205
$this->applyTypeFilter($query);
216206

217207
if ($this->search) {
218-
$normalizedSearch = $this->normalizeForSearch($this->search);
219-
220-
$allFilteredMovies = (clone $query)->get();
221-
$matchingIds = $allFilteredMovies->filter(function ($movie) use ($normalizedSearch) {
222-
return $this->matchesSearch($movie->title, $normalizedSearch)
223-
|| $this->matchesSearch($movie->director, $normalizedSearch)
224-
|| $this->matchesSearch($movie->original_title, $normalizedSearch);
225-
})->pluck('id');
226-
227-
$query->whereIn('id', $matchingIds);
208+
$this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'director', 'original_title']);
228209
}
229210

230211
if ($sortBy === 'runtime_minutes') {

0 commit comments

Comments
 (0)