feat(search): pg_trgm-Suchindex + Backend-Endpunkt statt Client-Filter
Command Palette lud bisher beim Öffnen ALLE Objekte und filterte nur clientseitig per .includes() - unscharf und würde bei mehr Objekten langsam werden. Jetzt: GIN-Trigram-Index auf objekt.name/code (Migration 0023), neuer GET /suche?q=-Endpunkt (Ähnlichkeits-Ranking per pg_trgm similarity()), Frontend debounced (250ms) statt vollständiger Objektliste. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
*~
|
*~
|
||||||
screenshots/
|
screenshots/
|
||||||
|
subagents-sammlung/
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""SEARCH-001: pg_trgm-Erweiterung + GIN-Trigram-Index auf Objekt.name/code.
|
||||||
|
|
||||||
|
Revision ID: 0023_suchindex
|
||||||
|
Revises: 0022_wartung
|
||||||
|
Create Date: 2026-09-08
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0023_suchindex"
|
||||||
|
down_revision: Union[str, None] = "0022_wartung"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||||
|
op.execute("CREATE INDEX ix_objekt_name_trgm ON objekt USING GIN (name gin_trgm_ops)")
|
||||||
|
op.execute("CREATE INDEX ix_objekt_code_trgm ON objekt USING GIN (code gin_trgm_ops)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_objekt_code_trgm")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_objekt_name_trgm")
|
||||||
|
op.execute("DROP EXTENSION IF EXISTS pg_trgm")
|
||||||
@@ -20,6 +20,7 @@ from app.api.v1.endpoints import (
|
|||||||
permission,
|
permission,
|
||||||
personal,
|
personal,
|
||||||
stammdaten,
|
stammdaten,
|
||||||
|
suche,
|
||||||
vorlagen,
|
vorlagen,
|
||||||
wartung,
|
wartung,
|
||||||
zustaendigkeit,
|
zustaendigkeit,
|
||||||
@@ -48,3 +49,4 @@ api_router.include_router(akte.router, tags=["akte"])
|
|||||||
api_router.include_router(lager.router, tags=["lager"])
|
api_router.include_router(lager.router, tags=["lager"])
|
||||||
api_router.include_router(ausgabe.router, tags=["ausgabe"])
|
api_router.include_router(ausgabe.router, tags=["ausgabe"])
|
||||||
api_router.include_router(wartung.router, tags=["wartung"])
|
api_router.include_router(wartung.router, tags=["wartung"])
|
||||||
|
api_router.include_router(suche.router, tags=["suche"])
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.objekt import Objekt
|
||||||
|
from app.schemas.suche import SuchTreffer
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_TREFFER_LIMIT = 8
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/suche", response_model=list[SuchTreffer])
|
||||||
|
async def suche(
|
||||||
|
q: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_=Depends(get_current_user),
|
||||||
|
) -> list[SuchTreffer]:
|
||||||
|
"""Globale Suche (Command Palette) über Objektname/-code, per pg_trgm-
|
||||||
|
Index statt vollständig geladener Objektliste + Client-Filter (SEARCH-001).
|
||||||
|
Kurze Eingaben (<2 Zeichen) liefern bewusst nichts - sonst zu viele
|
||||||
|
Zufallstreffer über den Ähnlichkeits-Schwellenwert von pg_trgm."""
|
||||||
|
q = q.strip()
|
||||||
|
if len(q) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
muster = f"%{q}%"
|
||||||
|
aehnlichkeit = func.greatest(func.similarity(Objekt.name, q), func.similarity(Objekt.code, q))
|
||||||
|
stmt = (
|
||||||
|
select(Objekt.id, Objekt.code, Objekt.name)
|
||||||
|
.where(or_(Objekt.name.ilike(muster), Objekt.code.ilike(muster), aehnlichkeit > 0.2))
|
||||||
|
.order_by(aehnlichkeit.desc())
|
||||||
|
.limit(_TREFFER_LIMIT)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return [SuchTreffer(id=r.id, code=r.code, name=r.name) for r in result.all()]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SuchTreffer(BaseModel):
|
||||||
|
id: int
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import auth_header, login
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_suche_findet_treffer_ueber_name_und_code(client, objekt_mit_position, mitarbeiter_user):
|
||||||
|
"""SEARCH-001: Objekt per (Teil-)Name UND per Code auffindbar."""
|
||||||
|
objekt, _ = objekt_mit_position
|
||||||
|
token = await login(client, "mitarbeiter1")
|
||||||
|
|
||||||
|
per_name = await client.get("/api/v1/suche?q=Kontroll-Test", headers=auth_header(token))
|
||||||
|
assert per_name.status_code == 200
|
||||||
|
treffer_ids = [t["id"] for t in per_name.json()]
|
||||||
|
assert objekt.id in treffer_ids
|
||||||
|
|
||||||
|
per_code = await client.get("/api/v1/suche?q=KT-01", headers=auth_header(token))
|
||||||
|
assert objekt.id in [t["id"] for t in per_code.json()]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_suche_zu_kurze_eingabe_liefert_leer(client, objekt_mit_position, mitarbeiter_user):
|
||||||
|
token = await login(client, "mitarbeiter1")
|
||||||
|
antwort = await client.get("/api/v1/suche?q=K", headers=auth_header(token))
|
||||||
|
assert antwort.status_code == 200
|
||||||
|
assert antwort.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_suche_ohne_login_abgelehnt(client, objekt_mit_position):
|
||||||
|
antwort = await client.get("/api/v1/suche?q=Kontroll")
|
||||||
|
assert antwort.status_code == 401
|
||||||
@@ -3,7 +3,12 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import { apiRequest } from "../api/client";
|
import { apiRequest } from "../api/client";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import type { Objekt } from "../api/types";
|
|
||||||
|
interface SuchTreffer {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Eintrag {
|
interface Eintrag {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,7 +27,7 @@ export function CommandPalette() {
|
|||||||
const { istVerantwortlich, istAdmin } = useAuth();
|
const { istVerantwortlich, istAdmin } = useAuth();
|
||||||
const [offen, setOffen] = useState(false);
|
const [offen, setOffen] = useState(false);
|
||||||
const [suche, setSuche] = useState("");
|
const [suche, setSuche] = useState("");
|
||||||
const [objekte, setObjekte] = useState<Objekt[]>([]);
|
const [objektTreffer, setObjektTreffer] = useState<SuchTreffer[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
@@ -44,13 +49,34 @@ export function CommandPalette() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (offen && objekte.length === 0) {
|
if (!offen) {
|
||||||
apiRequest<Objekt[]>("/objekte")
|
setSuche("");
|
||||||
.then(setObjekte)
|
setObjektTreffer([]);
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
}
|
||||||
if (!offen) setSuche("");
|
}, [offen]);
|
||||||
}, [offen, objekte.length]);
|
|
||||||
|
// Debounced Backend-Suche statt kompletter Objektliste + Client-Filter
|
||||||
|
// (SEARCH-001) - vorher wurden ALLE Objekte beim Öffnen geladen und nur
|
||||||
|
// clientseitig gefiltert, was bei vielen Objekten langsam/unscharf würde.
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = suche.trim();
|
||||||
|
if (trimmed.length < 2) {
|
||||||
|
setObjektTreffer([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let abgebrochen = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
apiRequest<SuchTreffer[]>(`/suche?q=${encodeURIComponent(trimmed)}`)
|
||||||
|
.then((treffer) => {
|
||||||
|
if (!abgebrochen) setObjektTreffer(treffer);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, 250);
|
||||||
|
return () => {
|
||||||
|
abgebrochen = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [suche]);
|
||||||
|
|
||||||
const aktionen = useMemo<Eintrag[]>(() => {
|
const aktionen = useMemo<Eintrag[]>(() => {
|
||||||
const liste: Eintrag[] = [
|
const liste: Eintrag[] = [
|
||||||
@@ -83,19 +109,16 @@ export function CommandPalette() {
|
|||||||
return liste;
|
return liste;
|
||||||
}, [navigate, istVerantwortlich, istAdmin]);
|
}, [navigate, istVerantwortlich, istAdmin]);
|
||||||
|
|
||||||
const objektTreffer = useMemo<Eintrag[]>(() => {
|
const objektEintraege = useMemo<Eintrag[]>(
|
||||||
if (suche.trim().length === 0) return [];
|
() =>
|
||||||
const s = suche.toLowerCase();
|
objektTreffer.map((o) => ({
|
||||||
return objekte
|
|
||||||
.filter((o) => o.name.toLowerCase().includes(s) || o.code.toLowerCase().includes(s))
|
|
||||||
.slice(0, 8)
|
|
||||||
.map((o) => ({
|
|
||||||
id: `objekt-${o.id}`,
|
id: `objekt-${o.id}`,
|
||||||
label: o.name,
|
label: o.name,
|
||||||
hinweis: o.code,
|
hinweis: o.code,
|
||||||
gehe: () => navigate(`/akte/objekt/${o.id}`),
|
gehe: () => navigate(`/akte/objekt/${o.id}`),
|
||||||
}));
|
})),
|
||||||
}, [suche, objekte, navigate]);
|
[objektTreffer, navigate]
|
||||||
|
);
|
||||||
|
|
||||||
const gefilterteAktionen = useMemo(() => {
|
const gefilterteAktionen = useMemo(() => {
|
||||||
if (suche.trim().length === 0) return aktionen;
|
if (suche.trim().length === 0) return aktionen;
|
||||||
@@ -105,7 +128,7 @@ export function CommandPalette() {
|
|||||||
|
|
||||||
if (!offen) return null;
|
if (!offen) return null;
|
||||||
|
|
||||||
const treffer = [...objektTreffer, ...gefilterteAktionen];
|
const treffer = [...objektEintraege, ...gefilterteAktionen];
|
||||||
|
|
||||||
function auswaehlen(eintrag: Eintrag) {
|
function auswaehlen(eintrag: Eintrag) {
|
||||||
eintrag.gehe();
|
eintrag.gehe();
|
||||||
|
|||||||
Reference in New Issue
Block a user