diff --git a/app/Services/Releases/ReleaseSearchService.php b/app/Services/Releases/ReleaseSearchService.php index c56bd957f..37792f0df 100644 --- a/app/Services/Releases/ReleaseSearchService.php +++ b/app/Services/Releases/ReleaseSearchService.php @@ -103,29 +103,37 @@ class ReleaseSearchService // Get order by clause $orderBy = $this->getBrowseOrder($orderBy === '' ? 'posted_desc' : $orderBy); - // Build final SQL with pagination - $sql = sprintf( - 'SELECT * FROM (%s) r ORDER BY r.%s %s LIMIT %d OFFSET %d', - $baseSql, - $orderBy[0], // @phpstan-ignore offsetAccess.notFound - $orderBy[1], // @phpstan-ignore offsetAccess.notFound - $limit, - $offset - ); + $pagedIdsSql = $this->buildSearchPageIdsSql($whereSql, $orderBy, $limit, $offset); // Check cache - $cacheKey = md5($this->getCacheVersion().$sql); + $cacheKey = md5($this->getCacheVersion().$pagedIdsSql.$baseSql); $releases = Cache::get($cacheKey); if ($releases !== null) { return $releases; } - // Execute query - $releases = Release::fromQuery($sql); + $pagedIds = array_map( + static fn (object $row): int => (int) $row->id, + DB::select($pagedIdsSql) + ); + + if ($pagedIds === []) { + return collect(); + } + + $pageWhereSql = sprintf('WHERE r.id IN (%s)', implode(',', $pagedIds)); + $detailSql = sprintf( + 'SELECT * FROM (%s) r ORDER BY FIELD(r.id, %s)', + $this->buildSearchBaseSql($pageWhereSql), + implode(',', $pagedIds) + ); + + // Execute query for the final page only. + $releases = Release::fromQuery($detailSql); // Add total count for pagination if ($releases->isNotEmpty()) { - $releases[0]->_totalrows = $this->getPagerCount($baseSql); + $releases[0]->_totalrows = $this->getReleasesCountForWhere($whereSql); } // Cache results @@ -1209,6 +1217,40 @@ class ReleaseSearchService ); } + /** + * Build the lightweight page-ID query for web search. + * This keeps the expensive joins out of the ORDER BY/LIMIT phase. + * + * @param array $orderBy + */ + private function buildSearchPageIdsSql(string $whereSql, array $orderBy, int $limit, int $offset): string + { + return sprintf( + 'SELECT r.id FROM releases r %s ORDER BY r.%s %s LIMIT %d OFFSET %d', + $whereSql, + $orderBy[0], // @phpstan-ignore offsetAccess.notFound + $orderBy[1], // @phpstan-ignore offsetAccess.notFound + $limit, + $offset + ); + } + + /** + * Get the total number of matching web-search releases without the expensive detail joins. + */ + private function getReleasesCountForWhere(string $whereSql): int + { + return $this->getPagerCount($this->buildSearchCountSql($whereSql)); + } + + /** + * Build the releases-only count query for web search pagination. + */ + private function buildSearchCountSql(string $whereSql): string + { + return sprintf('SELECT COUNT(*) as count FROM releases r %s', $whereSql); + } + /** * Get the passworded releases clause. */ diff --git a/tests/Unit/ReleaseSearchServiceOrderingTest.php b/tests/Unit/ReleaseSearchServiceOrderingTest.php index ccd92e0c1..2c36fc83b 100644 --- a/tests/Unit/ReleaseSearchServiceOrderingTest.php +++ b/tests/Unit/ReleaseSearchServiceOrderingTest.php @@ -130,4 +130,44 @@ class ReleaseSearchServiceOrderingTest extends TestCase $this->assertSame(550, $method->invoke($this->service, 50, 50)); $this->assertSame(2000, $method->invoke($this->service, 2500, 50)); } + + /** + * Test that the paged ID query only selects release IDs from the releases table. + */ + public function test_build_search_page_ids_sql_uses_releases_only(): void + { + $reflection = new ReflectionClass(ReleaseSearchService::class); + $method = $reflection->getMethod('buildSearchPageIdsSql'); + + $sql = $method->invoke( + $this->service, + 'WHERE r.passwordstatus = 0 AND r.id IN (1,2,3)', + ['postdate', 'desc'], + 50, + 0 + ); + + $this->assertSame( + 'SELECT r.id FROM releases r WHERE r.passwordstatus = 0 AND r.id IN (1,2,3) ORDER BY r.postdate desc LIMIT 50 OFFSET 0', + $sql + ); + $this->assertStringNotContainsString('JOIN', $sql); + } + + /** + * Test that the lightweight count query is built directly against releases. + */ + public function test_build_search_count_sql_uses_releases_only_count_query(): void + { + $reflection = new ReflectionClass(ReleaseSearchService::class); + $method = $reflection->getMethod('buildSearchCountSql'); + + $sql = $method->invoke($this->service, 'WHERE r.passwordstatus = 0 AND r.id IN (1,2,3)'); + + $this->assertSame( + 'SELECT COUNT(*) as count FROM releases r WHERE r.passwordstatus = 0 AND r.id IN (1,2,3)', + $sql + ); + $this->assertStringNotContainsString('JOIN', $sql); + } }