Skip to content

feat(search): wire idx_listing_fulltext for title+description keyword search#24

Merged
abdout merged 1 commit into
mainfrom
feat/search-fulltext
May 10, 2026
Merged

feat(search): wire idx_listing_fulltext for title+description keyword search#24
abdout merged 1 commit into
mainfrom
feat/search-fulltext

Conversation

@abdout
Copy link
Copy Markdown
Contributor

@abdout abdout commented May 9, 2026

Independent of every other PR in flight. Branched off main.

What

Turns the orphan idx_listing_fulltext GIN index — created in 20250922091446_add_performance_indexes/migration.sql — into a real keyword-search capability. Until now the index was paying for writes (every INSERT INTO "Listing" touched it) without contributing a single read.

-- The index that's been sitting unused since v1.0
CREATE INDEX IF NOT EXISTS idx_listing_fulltext ON "Listing" USING GIN (
  to_tsvector('english', COALESCE(title, '') || ' ' || COALESCE(description, ''))
);

How

SearchFilters gains an optional query field. When set, cachedListingSearch runs a small $queryRaw against the existing index to get matching listing IDs, then intersects the regular Prisma where-clause with id IN (...):

if (f.query) {
  const ids = await getFullTextMatchingIds(f.query);
  if (ids.length === 0) return [];
  where.id = { in: ids };
}
return db.listing.findMany({ where, ... });

Two-step instead of one big $queryRaw for the whole search because the rest of the where-clause (price ranges, amenity arrays, date-overlap subquery) is much cleaner through the Prisma builder. Keeping it typed is worth more than collapsing the round trips at this scale.

URL surface

/{lang}/listings?q=beach              ← short form, conventional
/{lang}/listings?query=beach          ← alias for clarity
/{lang}/listings?q=villa&priceMax=200 ← composes with all existing filters

The page also adds a quoted-keyword chip to the search summary so users can see why a result set looks unfamiliar:

"beach" · Khartoum · 2 guests · 2026-05-15 - 2026-05-22

What this does NOT do

  • No UI input yet. The URL contract is in place; a search box in big-search.tsx can wire to it as a follow-up. Server is forward-compatible.
  • No synonyms / language switching. The index was built with the 'english' dictionary so we keep that. Arabic content in title/description will tokenize on whitespace — good enough for now; a separate Arabic config is a future migration.
  • No re-indexing required. The GIN index already exists in production (verified by reading the migration history); this PR just starts using it.

Behavior matrix

query value Result
undefined / not set Today's exact path — no $queryRaw, no id IN (...) filter
"" or " " Validation error (Zod .trim().min(1)) — searchListings returns { success: false } rather than silently matching everything
"beach" plainto_tsquery('english', 'beach') — index lookup, IDs intersected with filters
"luxury beach" Parses as 'luxury' & 'beach' — both terms required
Match returns [] Short-circuits before findMany
Extension/index missing Helper catches → returns [] → empty result rather than 5xx

Validation

pnpm typecheck ✅ exit 0
pnpm vitest run ✅ 836 / 836 (added 3 tests for the new path)

Test plan after deploy

# 1. Existing search untouched
curl -s 'https://mk.databayt.org/en/listings' | head

# 2. Keyword search returns only matching titles/descriptions
curl -s 'https://mk.databayt.org/en/listings?q=beach' | head

# 3. Two-word query enforces AND
curl -s 'https://mk.databayt.org/en/listings?q=luxury+villa' | head

# 4. Empty match short-circuits
curl -s 'https://mk.databayt.org/en/listings?q=zzznever' | head

Diff stat

src/lib/schemas/search-schema.ts     | +11   query field added to SearchFilters + zod
src/lib/actions/search-actions.ts    | +51   getFullTextMatchingIds + branch in search/count
src/app/[lang]/listings/page.tsx     | +20   URL param wiring + summary chip
tests/actions/search-actions.test.ts | +43   3 new tests

🤖 Generated with Claude Code

@vercel
Copy link
Copy Markdown

vercel Bot commented May 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mkan Ready Ready Preview, Comment May 10, 2026 6:08am

… search

Until now the GIN(to_tsvector(...)) index from the
20250922091446_add_performance_indexes migration was orphaned —
declared in raw SQL but never referenced by any code, while every
search query went through Prisma's typed builder which can't model
the @@ operator. The index was costing writes (every Listing insert
touched the GIN) without contributing any read.

This PR makes it useful. SearchFilters gains an optional `query`
field. When set, cachedListingSearch runs a small $queryRaw against
the existing index to get matching listing IDs, then intersects the
regular Prisma where-clause with id IN (...). Two-step instead of
one big $queryRaw for the whole search because the rest of the
where-clause (price ranges, amenity arrays, date overlap subquery)
is much cleaner through the Prisma builder, and the wins from
keeping it typed are real.

Behavior:
  - empty / undefined query → no full-text branch, exactly today's path
  - non-empty query → GIN-backed substring + word match via
    plainto_tsquery('english', ...), so "luxury beach" parses as
    'luxury' & 'beach' and stop words are handled by the parser
  - empty match → short-circuits to [] without the listings findMany
  - extension missing → graceful fallback returns [] from the helper

URL surface:
  /[lang]/listings?q=beach          ← short form, conventional
  /[lang]/listings?query=beach      ← alias for clarity
  ?q + filters compose normally     ← e.g. ?q=villa&priceMax=200

Search summary on the page now shows the keyword as a quoted chip
("beach") so users see what they're filtering by.

What this does NOT do:
  - no UI input yet — that's the next PR. The URL contract is in
    place so a search box in big-search.tsx can wire to it directly.
  - no synonyms / stem language switching — the index was built with
    the 'english' dictionary so we keep that. Arabic content in
    title/description will tokenize on whitespace which is good enough
    for now; a separate Arabic config is a future migration.

Validation:
  pnpm typecheck → exit 0
  pnpm vitest run → 836 / 836  (3 new tests cover the query path,
                                  empty-match short-circuit, and the
                                  empty-string validation rejection)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@abdout abdout force-pushed the feat/search-fulltext branch from 2268378 to d347314 Compare May 10, 2026 06:06
@abdout abdout merged commit ea1505f into main May 10, 2026
8 checks passed
@abdout abdout deleted the feat/search-fulltext branch May 10, 2026 06:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant