Use whereLike for search so MySQL works alongside PostgreSQL (#302)

Seven search call sites used the `ilike` operator, which only PostgreSQL
understands. On MySQL they raise a syntax error, so post, asset, label,
signature and workspace-member search — plus the MCP list-posts tool —
were unusable on an engine `config/database.php` has always supported and
the docs advertise.

Replace them with `whereLike($column, $value)`, which the query grammars
translate per driver: PostgresGrammar emits `ilike` and MySqlGrammar emits
`like`. The generated SQL on PostgreSQL is therefore unchanged.

Verified by running the full suite on both engines:

  PostgreSQL 16    3888 passed, 0 failed
  MySQL 8.0.46     one pre-existing failure fixed, none introduced

Also adds case-insensitivity assertions to the five affected suites that
lacked them, and search coverage for ListPostsTool, which had none.

Note for MySQL installs: `like` is case-insensitive by virtue of the
column collation, not the operator. Under the default `utf8mb4_unicode_ci`
it is also accent-insensitive, so a search for "cafe" matches a stored
"café" — PostgreSQL's `ilike` does not. That difference comes from the
collation rather than this change. A `_bin` or `_cs` collation would make
search case-sensitive on both.

Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
This commit is contained in:
Jamie Ontiveros 2026-08-26 09:59:31 -04:00 committed by GitHub
parent 02e44b9785
commit 6e10e394a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 108 additions and 7 deletions

View file

@ -27,7 +27,7 @@ public static function query(Workspace $workspace, ?string $search = null, ?stri
->where('mediable_type', Relation::getMorphAlias(Workspace::class))
->where('mediable_id', $workspace->id)
->where('collection', 'assets')
->when(filled($search), fn (Builder $query) => $query->where('original_filename', 'ilike', '%'.trim($search).'%'))
->when(filled($search), fn (Builder $query) => $query->whereLike('original_filename', '%'.trim($search).'%'))
->when(filled($type), fn (Builder $query) => $query->where('type', $type))
->latest()
->orderByDesc('id');

View file

@ -48,7 +48,7 @@ public function search(Request $request): AnonymousResourceCollection
$type = $request->input('type');
$assets = $workspace->getMedia('assets')
->when($term !== '', fn ($query) => $query->where('original_filename', 'ilike', '%'.$term.'%'))
->when($term !== '', fn ($query) => $query->whereLike('original_filename', '%'.$term.'%'))
->when(in_array($type, ['image', 'video'], true), fn ($query) => $query->where('type', $type))
->latest()
->paginate(config('app.pagination.default'));

View file

@ -58,7 +58,7 @@ public function index(Request $request, ?string $status = null): Response|Redire
}
if ($search = $request->input('search')) {
$query->where('content', 'ilike', "%{$search}%");
$query->whereLike('content', "%{$search}%");
}
$labelIds = $request->collect('labels')

View file

@ -42,7 +42,7 @@ public function searchMembers(Request $request): AnonymousResourceCollection
$members = $workspace->members()
->where('users.id', '!=', $request->user()->id)
->when($term !== '', fn ($query) => $query->where('users.name', 'ilike', '%'.$term.'%'))
->when($term !== '', fn ($query) => $query->whereLike('users.name', '%'.$term.'%'))
->orderBy('users.name')
->limit(50)
->get(['users.id', 'users.name', 'users.email']);

View file

@ -26,7 +26,7 @@ public function index(Request $request): Response|RedirectResponse
$this->authorize('createPost', $workspace);
$labels = $workspace->labels()
->when($request->input('search'), fn ($query, $search) => $query->where('name', 'ilike', "%{$search}%"))
->when($request->input('search'), fn ($query, $search) => $query->whereLike('name', "%{$search}%"))
->latest()
->paginate(config('app.pagination.default'));

View file

@ -26,7 +26,7 @@ public function index(Request $request): Response|RedirectResponse
$this->authorize('createPost', $workspace);
$signatures = $workspace->signatures()
->when($request->input('search'), fn ($query, $search) => $query->where('name', 'ilike', "%{$search}%"))
->when($request->input('search'), fn ($query, $search) => $query->whereLike('name', "%{$search}%"))
->latest()
->paginate(config('app.pagination.default'));

View file

@ -45,7 +45,7 @@ public function handle(Request $request): ResponseFactory
};
if ($search = data_get($validated, 'search')) {
$query->where('content', 'ilike', '%'.$search.'%');
$query->whereLike('content', '%'.$search.'%');
}
$posts = $query->latest('scheduled_at')

View file

@ -66,6 +66,25 @@
->assertJsonPath('data.0.original_filename', 'campaign-hero.jpg');
});
test('filters assets by filename search case-insensitively', function () {
Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'original_filename' => 'CAMPAIGN-Hero.jpg',
]);
Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'original_filename' => 'office-shot.jpg',
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.assets.index', ['search' => 'campaign-hero']))
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.original_filename', 'CAMPAIGN-Hero.jpg');
});
test('paginates assets with the application page size', function () {
$perPage = (int) config('app.pagination.default');

View file

@ -61,6 +61,18 @@
$response->assertJsonPath('data.0.id', $matching->id);
});
test('assets search matches filenames case-insensitively', function () {
$matching = $this->workspace->addMedia(UploadedFile::fake()->image('VACATION-Beach.jpg'), 'assets');
$this->workspace->addMedia(UploadedFile::fake()->image('office-shot.jpg'), 'assets');
$response = $this->actingAs($this->user)
->getJson(route('app.assets.search', ['search' => 'vacation']));
$response->assertOk();
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $matching->id);
});
test('assets search filters by type', function () {
$this->workspace->addMedia(UploadedFile::fake()->image('photo.jpg'), 'assets');
$this->workspace->addMedia(UploadedFile::fake()->create('clip.mp4', 100, 'video/mp4'), 'assets');

View file

@ -65,6 +65,50 @@
});
});
test('list posts filters by content search', function () {
$matching = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Hello marketing world',
]);
Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Something else entirely',
]);
$response = TryPostServer::actingAs($this->user)
->tool(ListPostsTool::class, ['search' => 'marketing']);
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) use ($matching) {
$json->has('posts', 1, function (AssertableJson $post) use ($matching) {
$post->where('id', $matching->id)->etc();
})->etc();
});
});
test('list posts search is case insensitive', function () {
Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'MARKETING CAMPAIGN',
]);
Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Something else entirely',
]);
$response = TryPostServer::actingAs($this->user)
->tool(ListPostsTool::class, ['search' => 'marketing']);
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->has('posts', 1)->etc();
});
});
test('get post returns PostResource shape', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,

View file

@ -166,6 +166,19 @@
);
});
test('labels index search is case insensitive', function () {
WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'IMPORTANT']);
WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Urgent']);
$response = $this->actingAs($this->user)->get(route('app.labels.index', ['search' => 'important']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('labels.data', 1)
->where('filters.search', 'important')
);
});
test('labels index returns all when no search query', function () {
WorkspaceLabel::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);

View file

@ -157,6 +157,19 @@
);
});
test('signatures index search is case insensitive', function () {
WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'MARKETING']);
WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Travel']);
$response = $this->actingAs($this->user)->get(route('app.signatures.index', ['search' => 'marketing']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('signatures.data', 1)
->where('filters.search', 'marketing')
);
});
test('signatures index returns all when no search query', function () {
WorkspaceSignature::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);