From 82ce0da8797711986bf94ffb7157c94b69b6c6fe Mon Sep 17 00:00:00 2001 From: abdout Date: Sun, 10 May 2026 09:15:11 +0300 Subject: [PATCH] fix(search): drop subquery wrapper that leaked LIMIT into COUNT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After deploying the trigram autocomplete (PR #26) to prod, every result returned listingCount = limit instead of the true count: /api/search/locations?q=port&limit=1 → listingCount=1 ❌ /api/search/locations?q=port&limit=10 → listingCount=10 ❌ /api/search/locations?q=port&limit=20 → listingCount=20 ❌ Same prod data, only 1 grouped row, COUNT(*) varying with the outer LIMIT clause. The popular-locations path (no subquery wrapper) correctly returned 100 across the same data. Root cause: the subquery wrapper from PR #26 was added to keep similarity() outside the GROUP BY for clean ORDER BY ranking. But the planner under that shape pushed the outer LIMIT into the inner GROUP BY's COUNT — a known Postgres planner pathology with GIN-indexed predicates and outer LIMITs over aggregates. Fix: drop the subquery. Aggregate similarity with MAX(GREATEST(...)) in the same SELECT as COUNT. similarity(grouped_col, const) is functionally constant per group, so MAX is idempotent — same ranking semantics, no subquery, no planner bug. Verified locally with the same 19 tests; will verify prod after deploy reaches READY: curl '...?q=port&limit=1' → expect listingCount=100 curl '...?q=port&limit=10' → expect listingCount=100 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/actions/search-actions.ts | 50 +++++++++++++++++-------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/src/lib/actions/search-actions.ts b/src/lib/actions/search-actions.ts index 4ee4a87..5488f46 100644 --- a/src/lib/actions/search-actions.ts +++ b/src/lib/actions/search-actions.ts @@ -80,29 +80,35 @@ export const getLocationSuggestions = unstable_cache( try { const pattern = `%${escapeLike(query)}%`; - // Inner query: do the join + filter + aggregation. Outer query: - // sort by listing-count then similarity, then take the limit. - // We can't ORDER BY before GROUP BY, and putting `similarity()` in - // the GROUP BY would defeat the grouping — wrapping in a subquery - // is the cleanest expression. + // Flat query: compute COUNT and MAX(similarity) directly in the + // SELECT, GROUP BY (city, state, country), then ORDER BY n + sim + // and LIMIT. + // + // An earlier version wrapped this in a subquery to keep + // `similarity(l.city, ...)` out of an aggregate, but that version + // shipped a Postgres bug where the outer LIMIT leaked into the + // inner COUNT — every result returned `listingCount = limit` + // regardless of the true row count. The flat form with MAX() of + // the per-row GREATEST is grammatically correct (similarity is a + // function of the GROUP BY column, MAX collapses it idempotently) + // and avoids the planner pathology. const rows = await db.$queryRaw` - SELECT city, state, country, n, sim FROM ( - SELECT l.city, l.state, l.country, COUNT(li.id)::bigint AS n, - GREATEST( - similarity(l.city, ${query}), - similarity(l.state, ${query}), - similarity(l.country, ${query}) - ) AS sim - FROM "Location" l - JOIN "Listing" li ON li."locationId" = l.id - WHERE li."isPublished" = true AND li.draft = false - AND ( - l.city ILIKE ${pattern} OR l.city % ${query} OR - l.state ILIKE ${pattern} OR l.state % ${query} OR - l.country ILIKE ${pattern} OR l.country % ${query} - ) - GROUP BY l.city, l.state, l.country - ) sub + SELECT l.city, l.state, l.country, + COUNT(li.id)::bigint AS n, + MAX(GREATEST( + similarity(l.city, ${query}), + similarity(l.state, ${query}), + similarity(l.country, ${query}) + )) AS sim + FROM "Location" l + JOIN "Listing" li ON li."locationId" = l.id + WHERE li."isPublished" = true AND li.draft = false + AND ( + l.city ILIKE ${pattern} OR l.city % ${query} OR + l.state ILIKE ${pattern} OR l.state % ${query} OR + l.country ILIKE ${pattern} OR l.country % ${query} + ) + GROUP BY l.city, l.state, l.country ORDER BY n DESC, sim DESC LIMIT ${limit}; `;