From 31ec959c7d3dc97360c96c0a18d4b47542c6fbb4 Mon Sep 17 00:00:00 2001 From: Jamie Ontiveros <54843+jonto@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:38:54 -0400 Subject: [PATCH] Use whereLike for search so MySQL works alongside PostgreSQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/Actions/Media/ListWorkspaceAssets.php | 2 +- app/Http/Controllers/App/AssetController.php | 2 +- app/Http/Controllers/App/PostController.php | 2 +- .../Controllers/App/WorkspaceController.php | 2 +- .../App/WorkspaceLabelController.php | 2 +- .../App/WorkspaceSignatureController.php | 2 +- app/Mcp/Tools/Post/ListPostsTool.php | 2 +- tests/Feature/Api/AssetApiTest.php | 19 ++++++++ tests/Feature/AssetControllerTest.php | 12 +++++ tests/Feature/Mcp/PostToolTest.php | 44 +++++++++++++++++++ .../Feature/WorkspaceLabelControllerTest.php | 13 ++++++ .../WorkspaceSignatureControllerTest.php | 13 ++++++ 12 files changed, 108 insertions(+), 7 deletions(-) diff --git a/app/Actions/Media/ListWorkspaceAssets.php b/app/Actions/Media/ListWorkspaceAssets.php index 08833e859..6cc36904c 100644 --- a/app/Actions/Media/ListWorkspaceAssets.php +++ b/app/Actions/Media/ListWorkspaceAssets.php @@ -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'); diff --git a/app/Http/Controllers/App/AssetController.php b/app/Http/Controllers/App/AssetController.php index e6a772cee..e88a2dd33 100644 --- a/app/Http/Controllers/App/AssetController.php +++ b/app/Http/Controllers/App/AssetController.php @@ -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')); diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index 61981c895..c0ff2b59b 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -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') diff --git a/app/Http/Controllers/App/WorkspaceController.php b/app/Http/Controllers/App/WorkspaceController.php index ecc1c6e4d..74db86f69 100644 --- a/app/Http/Controllers/App/WorkspaceController.php +++ b/app/Http/Controllers/App/WorkspaceController.php @@ -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']); diff --git a/app/Http/Controllers/App/WorkspaceLabelController.php b/app/Http/Controllers/App/WorkspaceLabelController.php index bd904c221..137290000 100644 --- a/app/Http/Controllers/App/WorkspaceLabelController.php +++ b/app/Http/Controllers/App/WorkspaceLabelController.php @@ -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')); diff --git a/app/Http/Controllers/App/WorkspaceSignatureController.php b/app/Http/Controllers/App/WorkspaceSignatureController.php index f0d583ca2..281940025 100644 --- a/app/Http/Controllers/App/WorkspaceSignatureController.php +++ b/app/Http/Controllers/App/WorkspaceSignatureController.php @@ -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')); diff --git a/app/Mcp/Tools/Post/ListPostsTool.php b/app/Mcp/Tools/Post/ListPostsTool.php index 68d3bc5ea..c95d07c97 100644 --- a/app/Mcp/Tools/Post/ListPostsTool.php +++ b/app/Mcp/Tools/Post/ListPostsTool.php @@ -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') diff --git a/tests/Feature/Api/AssetApiTest.php b/tests/Feature/Api/AssetApiTest.php index c4ad4d4bb..f048e682c 100644 --- a/tests/Feature/Api/AssetApiTest.php +++ b/tests/Feature/Api/AssetApiTest.php @@ -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'); diff --git a/tests/Feature/AssetControllerTest.php b/tests/Feature/AssetControllerTest.php index f124ed3dc..2bfb196cb 100644 --- a/tests/Feature/AssetControllerTest.php +++ b/tests/Feature/AssetControllerTest.php @@ -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'); diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php index fa1937186..bcdd24042 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -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, diff --git a/tests/Feature/WorkspaceLabelControllerTest.php b/tests/Feature/WorkspaceLabelControllerTest.php index 408a3a86b..c4ecde282 100644 --- a/tests/Feature/WorkspaceLabelControllerTest.php +++ b/tests/Feature/WorkspaceLabelControllerTest.php @@ -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]); diff --git a/tests/Feature/WorkspaceSignatureControllerTest.php b/tests/Feature/WorkspaceSignatureControllerTest.php index 7c9502c88..e47c06a03 100644 --- a/tests/Feature/WorkspaceSignatureControllerTest.php +++ b/tests/Feature/WorkspaceSignatureControllerTest.php @@ -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]);