{
  "id": "add-postgres-full-text-search",
  "type": "playbooks",
  "category": "playbooks",
  "locale": "de",
  "url": "/de/playbooks/add-postgres-full-text-search",
  "title": "Prompt-to-PR: PostgreSQL-Volltextsuche hinzufügen",
  "description": "SOP zum Hinzufügen einer nativen PostgreSQL-Volltextsuche mit tsvector, GIN-Index, ts_rank und einer Next.js-Such-API – kein externer Suchdienst erforderlich.",
  "tools": [
    "Cursor",
    "Claude Code",
    "Codex",
    "Windsurf"
  ],
  "stack": [
    "Next.js",
    "PostgreSQL",
    "TypeScript"
  ],
  "tags": [
    "postgres",
    "nextjs",
    "typescript",
    "search",
    "sql"
  ],
  "difficulty": "hard",
  "updated": "2026-06-08",
  "markdown": "Die integrierte Volltextsuche von PostgreSQL deckt die meisten Produktsuchanforderungen ohne Elasticsearch oder Algolia ab. Dieses Playbook fügt eine `tsvector`-Spalte, einen GIN-Index und eine bewertete Suchabfrage hinter einer Next.js-API-Route hinzu.\n\n## 1. Anforderung\n\nFügen Sie eine Volltextsuche über eine `posts`-Tabelle hinzu (Spalten: `title`, `body`, `tags`). Die Suche soll Ergebnisse zurückgeben, die nach Relevanz geordnet sind und Ausschnitte hervorheben. Die Implementierung muss eine generierte `tsvector`-Spalte verwenden (nicht zur Abfragezeit berechnet), damit der GIN-Index verwendet wird.\n\n## 2. Erster Prompt\n\n```txt title=\"First Prompt\"\nAdd native PostgreSQL full-text search to the posts table in this project.\n\nDatabase: PostgreSQL. ORM/query builder: [Drizzle / postgres.js — use whichever\nis already in src/db/].\n\nStep 1 — migration:\n  Create a migration file (or Drizzle schema change) that:\n  a. Adds a generated column:\n       search_vector tsvector GENERATED ALWAYS AS (\n         setweight(to_tsvector('english', coalesce(title, '')), 'A') ||\n         setweight(to_tsvector('english', coalesce(body, '')), 'B') ||\n         setweight(to_tsvector('english', coalesce(tags, '')), 'C')\n       ) STORED;\n  b. Creates a GIN index on search_vector:\n       CREATE INDEX posts_search_idx ON posts USING gin(search_vector);\n\nStep 2 — query function:\n  Create `src/lib/queries/search.ts` exporting `searchPosts(q: string, limit = 10)`.\n  The query must:\n  - Convert the user query to a tsquery: `websearch_to_tsquery('english', $1)`.\n  - Filter: `search_vector @@ query`.\n  - Rank: `ts_rank(search_vector, query) DESC`.\n  - Return: id, title, ts_headline('english', body, query,\n      'MaxWords=30, MinWords=15, ShortWord=3, HighlightAll=false') AS snippet.\n  - Use parameterized query (no string interpolation of the user input).\n\nStep 3 — API route:\n  Create `src/app/api/search/route.ts` (GET). Read `q` from URL search params.\n  Return 400 if q is empty or shorter than 2 chars. Return JSON array of results.\n  Add cache-control: public, max-age=60.\n\nStep 4 — do not touch any UI components.\n```\n\n## 3. Erwartete Dateiänderungen\n\n```txt\nsrc/db/migrations/<timestamp>_add_search_vector.sql   (new — or Drizzle migration)\nsrc/db/schema.ts                                       (search_vector column if Drizzle)\nsrc/lib/queries/search.ts                              (new — searchPosts function)\nsrc/app/api/search/route.ts                            (new — GET endpoint)\n```\n\n## 4. Überprüfungsliste\n\n- `GENERATED ALWAYS AS ... STORED` — Die Spalte wird gespeichert (nicht virtuell), sodass der GIN-Index verwendet werden kann. Bestätigen Sie, dass das Schlüsselwort `STORED` vorhanden ist.\n- `websearch_to_tsquery` wird verwendet (nicht `to_tsquery`) – verarbeitet mehrwortige und Phrasenabfragen aus nicht vertrauenswürdigen Eingaben ohne Syntaxfehler.\n- Die vom Benutzer bereitgestellte Abfrage wird als SQL-Parameter übergeben, niemals interpoliert.\n- `ts_rank` ordnet die Ergebnisse – nicht alphabetisch oder nach Einfügereihenfolge.\n- Die Länge von `ts_headline` ist begrenzt (MaxWords=30), um zu große Ausschnitte zu vermeiden.\n- Der GIN-Indexname ist explizit – einfacher zu löschen/neu zu erstellen, falls nötig.\n- Die API-Route validiert die Mindestabfragelänge (2 Zeichen), um Volltabellenscans durch Abfragen wie `'a':*` zu vermeiden.\n- `Cache-Control: public, max-age=60` wird in der Antwort gesetzt – Suchergebnisse können kurzzeitig zwischengespeichert werden.\n\n## 5. Testbefehle\n\n```bash\n# Run the migration\nnpx drizzle-kit migrate\n# or: psql $DATABASE_URL < migration.sql\n\n# Verify the generated column and index exist\npsql $DATABASE_URL -c \"\\d posts\" | grep search_vector\npsql $DATABASE_URL -c \"\\di posts_search_idx\"\n\n# Query performance — confirm index scan, not seq scan\npsql $DATABASE_URL -c \"\n  EXPLAIN ANALYZE\n  SELECT id FROM posts\n  WHERE search_vector @@ websearch_to_tsquery('english', 'your test query')\n  LIMIT 10;\n\" | grep \"Index Scan\"\n\n# Test the API endpoint\nbun dev &\ncurl \"http://localhost:3000/api/search?q=your+test+query\" | jq .\n\n# Confirm empty query returns 400\ncurl -o /dev/null -w \"%{http_code}\" \"http://localhost:3000/api/search?q=\"\n# Expect: 400\n```\n\n## 6. Häufige Fehler\n\n- **Seq-Scan anstelle von Index-Scan** – `VIRTUAL`-Spalte (nicht `STORED`). Nur `STORED`-generierte Spalten können in PostgreSQL indiziert werden. Bestätigen Sie, dass die Migration `STORED` verwendet.\n- **`to_tsquery` wirft bei mehrwortigen Eingaben einen Fehler** – `to_tsquery('english', 'quick brown')` ist ein Syntaxfehler. Verwenden Sie für benutzereingegebene Zeichenfolgen immer `websearch_to_tsquery`.\n- **`ts_headline` gibt den gesamten Textkörper zurück** – `HighlightAll=true` versehentlich gesetzt oder `MaxWords` nicht gesetzt. Überprüfen Sie die Optionszeichenfolge.\n- **GIN-Index wird nicht für `LIKE`-Abfragen verwendet** – stellen Sie sicher, dass die WHERE-Klausel den `@@`-Operator verwendet, nicht `LIKE` oder `ILIKE`. Volltextsuche und `LIKE` sind getrennt.\n- **Migration schlägt bei vorhandenen Daten fehl** – die `GENERATED`-Spalte wird bei der Erstellung befüllt; bei großen Tabellen kann dies langsam sein. Führen Sie in einer Transaktion mit einer Fortschrittsprüfung durch.\n\n## 7. Fehlerbehebungsprompt\n\n```txt title=\"Fix Prompt\"\nEXPLAIN ANALYZE shows a sequential scan instead of an index scan on the\nposts table. The search_vector column was added as VIRTUAL (or without\nthe STORED keyword) so PostgreSQL cannot create a GIN index on it.\n\nFix the migration:\n  ALTER TABLE posts DROP COLUMN search_vector;\n  ALTER TABLE posts ADD COLUMN search_vector tsvector\n    GENERATED ALWAYS AS (\n      setweight(to_tsvector('english', coalesce(title, '')), 'A') ||\n      setweight(to_tsvector('english', coalesce(body, '')), 'B')\n    ) STORED;\n  CREATE INDEX posts_search_idx ON posts USING gin(search_vector);\n\nConfirm the word STORED appears in the column definition.\n```\n\n## 8. PR-Beschreibung\n\n```md title=\"PR description\"\n## Feature: PostgreSQL native full-text search on posts\n\n- Generated `tsvector` column (`STORED`) with weighted fields:\n  title (A), body (B), tags (C)\n- GIN index `posts_search_idx` — index scans confirmed via `EXPLAIN ANALYZE`\n- `searchPosts(q, limit)` uses `websearch_to_tsquery` (safe for user input)\n  and `ts_rank` for relevance ordering\n- `ts_headline` snippets (max 30 words) with matched terms highlighted\n- GET `/api/search?q=` — validates query length, returns ranked JSON,\n  `Cache-Control: public, max-age=60`\n\nNo third-party search dependency added.\n```"
}