From 9fa91eb684665668b5dee3749eff920fc42a1b30 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 10:39:02 +0100 Subject: [PATCH] fix(db): single filter set, filter tests with query, perf guards, evidence docs (KAT-01, 3a review) Filters are evaluated once into an id set used by both search branches. Adds tests for age/genre/mechanic filters with a search term, rank 2 vs 3 and null p_limit. approxNormalize uses NFKD. Perf script checks confirm_local value and unix socket and re-analyzes after rollback. Adds docs/specs/3-katalog-suche-umsetzung.md (measurements, log check incl. GET finding), release checklist section 2a and the CSV-only note for alt titles. Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- docs/datenmodell-r1.md | 4 +- docs/katalog-pflege.md | 4 +- docs/release-checkliste.md | 12 ++++ docs/specs/3-katalog-suche-umsetzung.md | 72 +++++++++++++++++++ supabase/catalog/build_startbestand.ts | 2 +- supabase/catalog/build_startbestand_test.ts | 1 + .../20261103235947_catalog_search.sql | 31 ++++---- supabase/perf/catalog_search_perf.sql | 24 ++++++- .../tests/database/09_catalog_search.test.sql | 18 ++++- 9 files changed, 145 insertions(+), 23 deletions(-) create mode 100644 docs/specs/3-katalog-suche-umsetzung.md diff --git a/docs/datenmodell-r1.md b/docs/datenmodell-r1.md index 627d3d1..17173b8 100644 --- a/docs/datenmodell-r1.md +++ b/docs/datenmodell-r1.md @@ -396,10 +396,10 @@ Spec: `docs/specs/3-katalog-suche.md`. Die Suche liest ausschließlich über die - **Erweiterungen:** `pg_trgm` und `unaccent` im Schema `extensions` (Bestandteil des Supabase-Postgres-Images; lokal geprüft, auf Staging/Produktion vor dem Merge mit `select * from pg_available_extensions where name in ('pg_trgm','unaccent')` bestätigen). - **Gemeinsame Normalisierung:** `public.search_normalize(text)` ist die einzige Basisfunktion für normalisierte Titel. Sie wird von #3 (PR 3a) angelegt, ist rein rechnend und für `anon`, `authenticated` und `service_role` ausführbar, weil generierte Spalten und `security invoker`-Funktionen sie mit den Rechten des Aufrufers berechnen. #13 legt `public.normalize_game_title(text)` darüber an und entfernt nur dort Artikel; `search_normalize` entfernt nie Artikel. Ablauf: Marken- und Schutzzeichen (`®©™℗℠`) entfernen, Akzente/Umlaute entfernen (`unaccent`, `ß` → `ss`, `Æ` → `ae`), Kleinschreibung, alles außer `a–z`/`0–9` zu einem Leerzeichen, Ränder kürzen, leer = `null`. Beispiele: `'CATAN®'` → `'catan'`, `'Spiel™ ½'` → `'spiel 1 2'`, `'Æon’s End'` → `'aeon s end'`, `'Café Größe'` → `'cafe grosse'`, `'!!!'` → `null`. Nicht-lateinische Zeichen werden entfernt. Ein Suchwort wie „kaese“ findet „Käse“ in R1 nicht (Spec-F5). -- **Generierte Spalten** (gespeichert, nicht beschreibbar): `games.title_search`, `game_alt_titles.title_search`, `publishers.name_search`, je mit Trigramm-Index (GIN; der Index auf `games` ist ein Partial-Index mit den Bedingungen der View `catalog_games`). Die Indizes sind keine Geschwindigkeitszusage, maßgeblich ist die Messung (`supabase/perf/catalog_search_perf.sql`, Ergebnis in der Beschreibung von PR 3a). Wird `search_normalize` oder `unaccent.rules` geändert (Image-Upgrade), müssen die Spalten per Migration neu angelegt werden; Prüfabfrage: `docs/katalog-pflege.md`, Abschnitt 8. +- **Generierte Spalten** (gespeichert, nicht beschreibbar): `games.title_search`, `game_alt_titles.title_search`, `publishers.name_search`, je mit Trigramm-Index (GIN; der Index auf `games` ist ein Partial-Index mit den Bedingungen der View `catalog_games`). Die Indizes sind keine Geschwindigkeitszusage, maßgeblich ist die Messung (`supabase/perf/catalog_search_perf.sql`, Ergebnisse und Pläne: `docs/specs/3-katalog-suche-umsetzung.md`). Wird `search_normalize` oder `unaccent.rules` geändert (Image-Upgrade), müssen die Spalten per Migration neu angelegt werden; Prüfabfrage: `docs/katalog-pflege.md`, Abschnitt 8. - **`manual_fields`:** neuer Schlüssel `alt_titles`. Der Check nennt jetzt 15 Schlüssel (siehe Tabellenstruktur); jede Migration nennt die vollständige Liste. - **Alternative Titel beim Zusammenführen:** `admin_merge_games` hängt `game_alt_titles` nicht um (Spec-F6 A); sie bleiben am ausgeblendeten Quell-Eintrag. Vor dem Zusammenführen ergänzt der Admin relevante Titel am Ziel (`docs/katalog-pflege.md`, Abschnitt 5, Schritt 2b). - **RPC `public.search_catalog_games(p_query, p_players, p_max_minutes, p_age, p_genre_ids, p_mechanic_ids, p_limit default 30, p_offset default 0)`** (`stable`, `security invoker`, leerer `search_path`; ausführbar für `anon` und `authenticated`, nicht für `public` und `service_role`). Vertrag und Semantik: Spec 4.2. Ausgabe je Treffer: `id`, `title`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path` (nur aus `catalog_games`, maskiert nach Quelle), `publisher_names`, `matched_alt_title`, `match_rank` (0 bis 4, ohne Suchbegriff `null`), `total_count`. Umsetzungsdetails, die der Vertrag offen ließ: `null` bei `p_limit`/`p_offset` gilt als Standardwert; „nur Leerraum“ (auch Tabulator, Zeilenumbruch) gilt als kein Suchbegriff; die Ähnlichkeit (zweites Sortierkriterium) ist die höchste Trigramm-Ähnlichkeit über Haupttitel und alle alternativen Titel; bei gleicher Stufe und Ähnlichkeit wird binär (`collate "C"`) nach `title_search`, dann nach `id` sortiert (ohne Suchbegriff ebenso nach `title_search`, dann `id`). Fehler (`P0001`, HTTP 400): `search_query_too_long`, `search_filter_invalid`, `search_paging_invalid`. Ein `smallint`-Parameter außerhalb von −32768…32767 scheitert schon beim Umwandeln (kein `P0001`). - **Rechte der Rolle `catalog_importer`:** Wer `games` schreibt, berechnet `title_search` mit den eigenen Rechten. Der spätere Import (SAM-08) braucht deshalb `execute` auf `public.search_normalize(text)`, `usage` auf das Schema `extensions` und Schreibrechte auf `game_alt_titles`; die Test-Rolle in `06_catalog_foundation.test.sql` bekommt die beiden ersten. -- **Suchbegriffe** werden nicht gespeichert; der Aufruf per `POST` (`client.rpc`) hält sie aus den URLs der Zugriffsprotokolle. Protokoll-Einstellungen der Datenbank: Ergebnis der Prüfung in der Beschreibung von PR 3a. +- **Suchbegriffe** werden nicht gespeichert; der Aufruf per `POST` (`client.rpc`) hält sie aus den URLs der Zugriffsprotokolle. Protokoll-Einstellungen der Datenbank und der Befund zu GET-Aufrufen: `docs/specs/3-katalog-suche-umsetzung.md`, Abschnitt Protokollprüfung. - **API-Änderungen für das Frontend:** neu lesbar: Tabelle `game_alt_titles`, Spalten `games.title_search` und `publishers.name_search` (über `select=*` mitgeliefert, die App liest sie nicht), RPC `search_catalog_games`. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGameSummary` (3b) folgt der Ausgabe oben. diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index be81c14..17e4a48 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -60,7 +60,7 @@ Kopfzeile ist Pflicht, Trennzeichen Komma, Felder mit Komma oder Zeilenumbruch i | `description_locale` | ja | immer `de` | | `hidden` | nein | `ja` = ausblenden; leer = `hidden_at` bleibt unverändert | | `facts_source` | nein | Herkunftsnachweis für SB3; nicht in der Datenbank | -| `alt_titles` | nein | alternative Titel mit Sprache: `de:Titel;en:Title`, Einträge durch `;` getrennt, Sprache `de` oder `en`, am **ersten** `:` getrennt (`de:Catan: Das Duell` ist zulässig). **`;` ist in einem alternativen Titel nicht erlaubt** (kein Escaping; solche Titel im Studio pflegen, siehe 3.1). Das Werkzeug bricht mit Zeilennummer ab bei: Sprache nicht `de`/`en`, Eintrag ohne `:`, leerem oder über 200 Zeichen langem Titel, einem Titel nur aus Sonderzeichen, demselben Titel in derselben Sprache doppelt oder gleich dem Haupttitel (ohne Beachtung von Schreibweise und Sonderzeichen). Eine leere Spalte entfernt früher eingespielte alternative Titel des Spiels (bis zum Go-live). | +| `alt_titles` | nein | alternative Titel mit Sprache: `de:Titel;en:Title`, Einträge durch `;` getrennt, Sprache `de` oder `en`, am **ersten** `:` getrennt (`de:Catan: Das Duell` ist zulässig). **`;` ist in einem alternativen Titel nicht erlaubt** (kein Escaping; solche Titel im Studio pflegen, siehe 3.1). Das Werkzeug bricht mit Zeilennummer ab bei: Sprache nicht `de`/`en`, Eintrag ohne `:`, leerem oder über 200 Zeichen langem Titel, einem Titel nur aus Sonderzeichen, demselben Titel in derselben Sprache doppelt oder gleich dem Haupttitel (ohne Beachtung von Schreibweise und Sonderzeichen). Eine leere Spalte entfernt früher eingespielte alternative Titel des Spiels (bis zum Go-live). **Bis zum Go-live gehören die alternativen Titel von Startbestandsspielen der CSV:** Das nächste Einspielen von `startbestand.sql` löscht alle Titel, die nicht in der Spalte stehen, also auch im Studio ergänzte. | | `base_game_key` | nein | reserviert für KAT-03: `key` des Basisspiels (SB7) | Andere Spalten (Cover, Quelle, BGG-Kennung) lehnt das Werkzeug ab. @@ -170,7 +170,7 @@ Regeln: ### 3.1 Alternative Titel pflegen -Ein Spiel kann zusätzlich zum Haupttitel alternative Titel mit Sprache (`de` oder `en`) haben, zum Beispiel den deutschen und den englischen Titel. Die Suche findet das Spiel auch darüber (KAT-01). Pflege nur über Studio oder die CSV-Spalte `alt_titles` (siehe 2.3); API-Rollen können sie nicht schreiben. Der Trigger trägt `alt_titles` in `games.manual_fields` ein. Ein Titel nur aus Sonderzeichen wird abgelehnt; derselbe Titel in derselben Sprache ist nur einmal möglich, egal in welcher Schreibweise. Beispiel-ID wie in 3: `7a1b2c3d-0000-4000-8000-000000000001`. +Ein Spiel kann zusätzlich zum Haupttitel alternative Titel mit Sprache (`de` oder `en`) haben, zum Beispiel den deutschen und den englischen Titel. Die Suche findet das Spiel auch darüber (KAT-01). Pflege nur über Studio oder die CSV-Spalte `alt_titles` (siehe 2.3); API-Rollen können sie nicht schreiben. **Bei Startbestandsspielen bis zum Go-live nur über die CSV pflegen:** Sonst entfernt das nächste Einspielen von `startbestand.sql` die im Studio angelegten Titel wieder (Abgleich wie bei Verlagen und Genres). Nach dem Go-live (Startbestand eingefroren) und für alle anderen Spiele ist das Studio der Weg. Der Trigger trägt `alt_titles` in `games.manual_fields` ein. Ein Titel nur aus Sonderzeichen wird abgelehnt; derselbe Titel in derselben Sprache ist nur einmal möglich, egal in welcher Schreibweise. Beispiel-ID wie in 3: `7a1b2c3d-0000-4000-8000-000000000001`. **Anlegen oder korrigieren** (ein vorhandener Titel in anderer Schreibweise wird aktualisiert statt doppelt angelegt): diff --git a/docs/release-checkliste.md b/docs/release-checkliste.md index f42f9bf..e19ab63 100644 --- a/docs/release-checkliste.md +++ b/docs/release-checkliste.md @@ -41,6 +41,18 @@ Reihenfolge: Migrationen, `vocabulary.sql`, `startbestand.sql` (Runbook `docs/ka Issue #2 wird erst nach der Abnahme von `checks.sql` auf Produktion (C1) und nach Fertigstellung von #4 (KAT-02) geschlossen, und nur von Chris. +## 2a. Katalogsuche (Spec #3, KAT-01; Belege: `docs/specs/3-katalog-suche-umsetzung.md`) + +| ☐ | Punkt | Wer | Prüfung | +|---|---|---|---| +| ☐ | **Erweiterungen** `pg_trgm` und `unaccent` verfügbar, bevor die Migration `catalog_search` läuft | devops | `select * from pg_available_extensions where name in ('pg_trgm', 'unaccent');` liefert zwei Zeilen | +| ☐ | **`statement_timeout`** der Rollen `anon` und `authenticated` gesetzt (lokal 3 s und 8 s) | devops | `select rolname, rolconfig from pg_roles where rolname in ('anon', 'authenticated');` | +| ☐ | **Protokoll-Einstellungen** halten Suchbegriffe aus den Datenbank-Logs: `log_parameter_max_length_on_error = 0`, `log_min_duration_statement = -1`, `log_statement` höchstens `ddl`, `pgaudit.log = none`, und `auto_explain.log_min_duration` größer als das längste Rollen-Zeitlimit | devops | Abfrage aus `docs/katalog-pflege.md` 8.2; danach eine Suche mit Markerbegriff absetzen und DB-, PostgREST- und Gateway-Logs durchsuchen (Marker darf nicht vorkommen) | +| ☐ | **GET auf die RPC sperren oder aus dem Zugriffsprotokoll halten:** `GET /rest/v1/rpc/search_catalog_games?p_query=…` ist technisch möglich und schreibt den Suchbegriff ins Zugriffsprotokoll des Gateways (lokal bestätigt; die App nutzt POST) | devops | Im Proxy/Kong nur POST auf diese Route zulassen oder Query-String nicht protokollieren; Gegenprobe mit Markerbegriff per GET | +| ☐ | **Prüfabfrage R6** (Normalisierung) nach jedem Image-Upgrade und vor dem Release: überall 0 | devops | Abfrage aus `docs/katalog-pflege.md` 8.1 | +| ☐ | **Krit. 7 gegen Staging** messen (Playwright-Projekt `perf`, 3c) und die Datenbankzeit mit `supabase/perf/catalog_search_perf.sql` nur auf einer lokalen Kopie; Budget p95 ≤ 150 ms in der Datenbank bei 10 000 Spielen | Chris, devops | Ergebnis im Release-PR; lokal war das Budget im ruhigen Lauf erfüllt, auf belastetem Host nicht | +| ☐ | **Mengenbegrenzung je IP** für `search_catalog_games` entschieden (Kong `rate-limiting` oder `limit_req`) | Chris, devops | Entscheidung im Hosting-Runbook | + ## 3. Auth, SMTP und Gateway (Spec #1, D1, Issue #46) Vollständige Liste mit AUTH-CFG-Verweisen: `docs/auth-konfiguration.md` § 5; Gateway-Vorgaben § 3. Release-Blocker: diff --git a/docs/specs/3-katalog-suche-umsetzung.md b/docs/specs/3-katalog-suche-umsetzung.md new file mode 100644 index 0000000..83fcf8e --- /dev/null +++ b/docs/specs/3-katalog-suche-umsetzung.md @@ -0,0 +1,72 @@ +# Spec #3 – KAT-01 Spiele suchen: Umsetzungs-Nachtrag PR 3a (Backend) + +Ergänzt `docs/specs/3-katalog-suche.md` (die Spec selbst wird hier nicht geändert). Stand: 2026-11-04, Branch `feature/3a-katalog-suche-backend`, Migration `20261103235947_catalog_search.sql`, Tests `supabase/tests/database/09_catalog_search.test.sql`. + +## 1. Abweichungen und Präzisierungen zum Vertrag + +- `null` für `p_limit`/`p_offset` gilt als Standardwert (30/0). Nur Leerraum (auch Tab, Zeilenumbruch) gilt als kein Suchbegriff. +- Ein `smallint`-Parameter außerhalb des Typbereichs scheitert beim Umwandeln mit 22003, nicht mit P0001. +- Die Ähnlichkeit (zweites Sortierkriterium) ist das Maximum über Haupttitel und alle alternativen Titel. Sortierung binär (`collate "C"`), dann `id`. +- Rang 0 prüft nur `replace(ts,' ','') = qc` (schließt `ts = q` ein). +- Von mehreren treffenden alternativen Titeln gewinnt die beste Stufe, dann die größte Ähnlichkeit, dann die Schreibweise. Bei gleicher Stufe von Haupt- und alternativem Titel ist `matched_alt_title` null. +- **Stufe 4 trifft auch ohne Verlag:** Jedes Suchwort muss in einem Titel (Haupt- oder alternativer Titel) oder einem Verlagsnamen des Spiels vorkommen. Verteilen sich die Wörter auf Haupt- und alternativen Titel (kein einzelner Titel enthält alle), ist der Treffer ebenfalls Stufe 4, obwohl kein Verlag beteiligt ist. Die Spec nennt als Stufe-4-Fall nur den Verlagstreffer. +- Doppelt eingegebene Wörter zählen einfach. +- **Filter einmal berechnet:** Spielerzahl, Dauer, Alter, Genre und Mechanik werden zu einer ID-Menge ausgewertet, die beide Zweige (mit und ohne Suchbegriff) nutzen. +- **Messskript unter `supabase/perf/`** statt `supabase/tests/perf/`: `supabase-db-tests.sh` und `supabase test db` laufen rekursiv über `tests/` und würden es mitlaufen lassen. +- Testplan: `00_schema` 22 Tabellen; `07`/`08` tragen `game_alt_titles.game_id` als "bewusst ausgelassen" ein; `06` bekam die Prüfabfrage-Zeile `Info-Alt` (statt einer zweiten `Info`) und Grants für die Test-Rolle `catalog_importer`. +- **Für SAM-08:** Die Import-Rolle braucht `execute` auf `public.search_normalize(text)`, `usage` auf das Schema `extensions` und Schreibrechte auf `game_alt_titles`. +- **Performance-Fehler der ersten Fassung:** korrelierte Unterabfragen je Spiel (Rang 4, Filter) brauchten 13 s bei 10 000 Spielen. Behoben durch Mengenoperationen (`word_hits`/`word_cover`, gehashte ID-Menge, `like all`, Ähnlichkeit nur für Treffer). Stufen 1 und 2 (Kandidaten-CTE, `title_compact`) sind nicht gebaut. + +## 2. Messung B5 (lokal, Docker auf macOS, Postgres 17.11) + +Skript `supabase/perf/catalog_search_perf.sql`: 10 000 Katalogspiele, 20 002 alternative Titel, 2 000 Verlage, 12 919 Verlagszuordnungen, je Spiel 2 Genres und 3 Mechaniken; eine Transaktion mit Rollback (danach `vacuum (analyze)`, weil `ANALYZE` nicht zurückrollbar ist); Messung als `anon` mit RLS, `set local role anon`, Generic Plans (ab der 6. Ausführung, wie bei PostgREST-Verbindungen). Das Rollen-`statement_timeout` (3 s) gilt bei `set local role` nicht und wird nicht angewandt; Aufrufe über 3 s wurden gezählt (keiner). + +### 2.1 Zeiten je Fall (Endstand, n = 100 je Fall, 1400 Aufrufe, Host-Last 1,7 bis 2,2) + +| Fall | p50 ms | p95 ms | p99 ms | max ms | +|---|---|---|---|---| +| 01 Titelteil (4 Zeichen) | 70,4 | 77,4 | 84,8 | 115,8 | +| 02 zwei Zeichen | 77,8 | 90,7 | 112,6 | 116,6 | +| 03 ganzes Wort | 70,4 | 75,8 | 78,6 | 82,8 | +| 04 zwei Wörter | 73,5 | 77,9 | 89,0 | 105,2 | +| 05 ohne Leerzeichen | 69,4 | 73,4 | 85,2 | 98,4 | +| 06 Verlag (ein Wort) | 69,8 | 73,1 | 81,9 | 91,8 | +| 06b Verlag + häufiges Wort | 76,6 | 81,3 | 86,6 | 147,7 | +| 07 alternativer Titel | 70,1 | 77,9 | 87,2 | 112,4 | +| 08 Begriff + Filter | 18,6 | 20,5 | 22,6 | 23,4 | +| 09 nur Filter (Spielerzahl, Genre) | 8,5 | 8,7 | 8,8 | 8,9 | +| 10 nur alle Filter | 10,7 | 11,3 | 12,8 | 12,8 | +| 11 ohne alles, erste Seite | 8,9 | 9,4 | 10,4 | 11,4 | +| 12 ohne alles, Versatz 5000 | 10,7 | 12,3 | 13,3 | 13,3 | +| 13 ohne Treffer | 66,3 | 72,3 | 93,7 | 101,3 | +| **alle** | | **78,6** | | 147,7 | + +Budget p95 ≤ 150 ms: erfüllt. Frühere Läufe derselben Fassung (vor dem einmaligen Filter-Set): ruhiger Host gesamt 110 ms (je Fall 77 bis 131 ms); auf einem stark belasteten Host (Last 8 bis 15) je Textfall 190 bis 325 ms, gesamt 215 ms, also über Budget. Das Ergebnis hängt von der CPU-Verfügbarkeit ab. Die Fixkosten sind der Volltext-Scan über rund 30 000 Titel (Suche ohne Treffer: 66 bis 72 ms). **Hetzner/Staging ist nicht gemessen.** + +### 2.2 Pläne (`auto_explain`, `analyze`, `buffers`, als `anon` mit RLS) + +Das Skript gibt für alle 13 Fälle den Plan mit Zeilenzahlen und Buffers als NOTICE aus (`log_timing` aus, weil die Zeitmessung selbst die Zeiten stark verfälscht). Gesamtdauer der Anweisung und Buffers, sechs Fälle: + +| Fall | Dauer der Anweisung | Treffer (Zeilen) | Buffers (shared hit) | Plan | +|---|---|---|---|---| +| 01 Titelteil | 47,7 ms | 30 | 10 102 | Custom Plan | +| 02 zwei Zeichen | 75,8 ms | 30 | 9 795 | Custom Plan | +| 06 Verlag | 75,1 ms | 11 | 177 098 | Generic Plan | +| 08 Begriff + Filter | 21,5 ms | 2 | 26 236 | Generic Plan | +| 10 alle Filter | 13,3 ms | 30 | 14 437 | Generic Plan | +| 13 ohne Treffer | 72,2 ms | 0 | 176 884 | Generic Plan | + +Die Pläne zeigen `Seq Scan` auf `games` (View und Join) und auf `game_alt_titles`; die Trigramm-Indizes werden nicht genutzt (Zweizeichen-Begriffe, Regel ohne Leerzeichen, RLS-Prüfungen für `anon`), wie in Spec 3.2 Nr. 3 vorhergesagt. Die Generic Plans lesen mehr Buffers (RLS-Probe auf `games` je Verlagszeile), bleiben aber in der Zeit gleich. + +## 3. Protokollprüfung (QUE-04) + +Lokale Werte: `log_min_duration_statement = -1`, `log_statement = ddl`, `log_parameter_max_length = -1`, `log_parameter_max_length_on_error = 0`, `pgaudit.log = none`, `pgaudit.log_parameter = off`, `auto_explain.log_min_duration = 10 s` mit `auto_explain.log_parameter_max_length = -1`. + +- **POST über Kong:** Ein Aufruf mit dem Marker `geheimerbegriff9z` (Erfolg und Fehler `search_query_too_long`, `search_paging_invalid`) steht weder im Datenbank- noch im PostgREST-Protokoll. Fehler stehen mit dem Platzhalter `$1` im Protokoll (`STATEMENT: WITH pgrst_source AS (SELECT $1 AS json_data) …`), weil `log_parameter_max_length_on_error = 0`. +- **GET über Kong (Befund der Review, am 2026-11-04 bestätigt):** `GET /rest/v1/rpc/search_catalog_games?p_query=markergetxyz` liefert 200 und steht im **Zugriffsprotokoll des Gateways (Kong)**; das POST-Gegenstück (`markerpostxyz`) nicht. Datenbank-Log in beiden Fällen ohne Marker. Die App nutzt POST; der GET-Weg muss im Proxy gesperrt werden (Release-Checkliste 2a). +- **Abhängigkeit:** Suchbegriffe bleiben nur deshalb aus dem `auto_explain`-Log, weil die Rollen-Zeitlimits (3 s, 8 s) kürzer als 10 s sind und abgebrochene Anweisungen keine `auto_explain`-Ausgabe erzeugen. `log_parameter_max_length_on_error`, `auto_explain.log_min_duration` und die Rollen-Zeitlimits müssen zusammen geprüft werden (pgTAP prüft die lokalen Annahmen; Runbook `docs/katalog-pflege.md` 8.2). +- Hetzner/Staging nicht geprüft. + +## 4. Mutationsprüfung + +Mutationen in einer Kopie der Testdatei (Rollback), nicht am echten Schema: 103 von 107 SQL-Mutationen (RPC, `search_normalize`, Rechte, Tabelle, Check, Indizes) erkannt; Überlebende: ID-Tie-Break innen und außen (der Upstream-Plan liefert bereits ID-Reihenfolge), beide `collate "C"`-Mutationen (lokal ICU-Sortierung, für `[a-z0-9 ]` gleich). Fixtures: 4 von 5 (überlebt: Rohtitel-Vergleich, gleicher Endzustand durch die Semantik datenändernder CTEs). Deno-Werkzeug: 17 von 17. diff --git a/supabase/catalog/build_startbestand.ts b/supabase/catalog/build_startbestand.ts index a1afa85..5a06691 100644 --- a/supabase/catalog/build_startbestand.ts +++ b/supabase/catalog/build_startbestand.ts @@ -285,7 +285,7 @@ export function approxNormalize(s: string): string { .replace(/[®©™℗℠]/g, "") .toLowerCase() .replace(/[ßæœøđłðþ]/g, (c) => FOLD[c]) - .normalize("NFD") + .normalize("NFKD") .replace(/\p{M}+/gu, "") .replace(/[^a-z0-9]+/g, " ") .trim(); diff --git a/supabase/catalog/build_startbestand_test.ts b/supabase/catalog/build_startbestand_test.ts index f096067..dfa0cb5 100644 --- a/supabase/catalog/build_startbestand_test.ts +++ b/supabase/catalog/build_startbestand_test.ts @@ -311,6 +311,7 @@ Deno.test("approxNormalize: Marken- und Schutzzeichen, Akzente, ß, Æ, Sonderze assert.equal(approxNormalize("7 Wonders: Duel"), "7 wonders duel"); assert.equal(approxNormalize("Ticket to Ride – Europe"), "ticket to ride europe"); assert.equal(approxNormalize("!!!"), ""); + assert.equal(approxNormalize("Spiel™ ½"), "spiel 1 2"); // Bruch wie unaccent: NFKD }); Deno.test("SQL alt_titles: Abgleich über normalisierten Titel, Update nur bei geänderter Schreibweise", async () => { diff --git a/supabase/migrations/20261103235947_catalog_search.sql b/supabase/migrations/20261103235947_catalog_search.sql index 70776a2..7616599 100644 --- a/supabase/migrations/20261103235947_catalog_search.sql +++ b/supabase/migrations/20261103235947_catalog_search.sql @@ -159,6 +159,8 @@ declare v_mechanics uuid[]; v_genre_n integer; v_mech_n integer; + v_filtered boolean; + v_ids uuid[]; -- Spiele, die alle gesetzten Filter erfüllen (einmal berechnet) begin -- Eingabegrenzen (Fehler statt stilles Abschneiden) if p_query is not null and char_length(p_query) > 100 then @@ -199,6 +201,19 @@ begin v_genre_n := cardinality(v_genres); v_mech_n := cardinality(v_mechanics); + -- Die Filter werden einmal zu einer ID-Menge ausgewertet; beide Zweige unten nutzen sie. + v_filtered := p_players is not null or p_max_minutes is not null or p_age is not null or v_genre_n > 0 or v_mech_n > 0; + if v_filtered then + v_ids := array(select c.id from public.catalog_games c + where (p_players is null or (c.min_players <= p_players and p_players <= c.max_players)) + and (p_max_minutes is null or coalesce(c.max_minutes, c.min_minutes) <= p_max_minutes) + and (p_age is null or c.min_age <= p_age) + and (v_genre_n = 0 or c.id in (select gg.game_id from public.game_genres gg where gg.genre_id = any (v_genres) + group by gg.game_id having count(*) = v_genre_n)) + and (v_mech_n = 0 or c.id in (select gm.game_id from public.game_mechanics gm where gm.mechanic_id = any (v_mechanics) + group by gm.game_id having count(*) = v_mech_n))); + end if; + if not v_has_query then -- Ohne Suchbegriff: Filter wirken, Sortierung nach Titel, match_rank ist null. return query @@ -214,13 +229,7 @@ begin c.image_path, g.title_search, count(*) over () as total from public.catalog_games c join public.games g on g.id = c.id - where (p_players is null or (c.min_players <= p_players and p_players <= c.max_players)) - and (p_max_minutes is null or coalesce(c.max_minutes, c.min_minutes) <= p_max_minutes) - and (p_age is null or c.min_age <= p_age) - and (v_genre_n = 0 or c.id in (select gg.game_id from public.game_genres gg where gg.genre_id = any (v_genres) - group by gg.game_id having count(*) = v_genre_n)) - and (v_mech_n = 0 or c.id in (select gm.game_id from public.game_mechanics gm where gm.mechanic_id = any (v_mechanics) - group by gm.game_id having count(*) = v_mech_n)) + where (not v_filtered or c.id in (select unnest(v_ids))) order by g.title_search collate "C", c.id limit v_limit offset v_offset ) s @@ -234,13 +243,7 @@ begin c.image_path, g.title_search from public.catalog_games c join public.games g on g.id = c.id - where (p_players is null or (c.min_players <= p_players and p_players <= c.max_players)) - and (p_max_minutes is null or coalesce(c.max_minutes, c.min_minutes) <= p_max_minutes) - and (p_age is null or c.min_age <= p_age) - and (v_genre_n = 0 or c.id in (select gg.game_id from public.game_genres gg where gg.genre_id = any (v_genres) - group by gg.game_id having count(*) = v_genre_n)) - and (v_mech_n = 0 or c.id in (select gm.game_id from public.game_mechanics gm where gm.mechanic_id = any (v_mechanics) - group by gm.game_id having count(*) = v_mech_n)) + where (not v_filtered or c.id in (select unnest(v_ids))) ), -- Kandidaten-Titel: Haupttitel (alt_title null) und alle alternativen Titel cand as ( diff --git a/supabase/perf/catalog_search_perf.sql b/supabase/perf/catalog_search_perf.sql index 0c3e7be..af6df50 100644 --- a/supabase/perf/catalog_search_perf.sql +++ b/supabase/perf/catalog_search_perf.sql @@ -3,13 +3,17 @@ -- -- Was es tut: legt in EINER Transaktion synthetische Daten an (Standard: 10 000 sichtbare Katalogspiele, -- 20 000 alternative Titel, 2 000 Verlage, je Spiel zwei Genres und drei Mechaniken), führt ANALYZE aus und --- misst search_catalog_games ALS anon mit Row Level Security. Am Ende steht ROLLBACK: nichts bleibt zurück. +-- misst search_catalog_games ALS anon mit Row Level Security. Am Ende steht ROLLBACK: die Daten bleiben nicht +-- zurück. Achtung: ANALYZE ist NICHT rollback-fähig, die Planer-Statistiken (pg_statistic) der Tabellen zeigen +-- danach die synthetischen Daten. Deshalb läuft nach dem ROLLBACK ein vacuum (analyze) der betroffenen Tabellen. -- 1. Phase A: je typischer Fall ein Aufruf mit auto_explain (Plan mit Zeiten und Buffers kommt als NOTICE). -- 2. Phase B: je Fall n_iter (Standard 100) Aufrufe mit wechselnden Begriffen, Ausgabe p50/p95/p99/max in ms. -- -- Nur lokal! Nie gegen Staging oder Produktion (die Daten werden zwar zurückgerollt, die Last und die Sperren --- sind es nicht). Schutz: Aufruf nur mit -v confirm_local=yes, und das Skript bricht ab, wenn die Datenbank --- nach echten Daten aussieht (mehr als 20 Konten oder 20 private Spiele). +-- sind es nicht). Schutz in drei Stufen: (1) -v confirm_local=yes (der Wert wird geprüft), (2) die Verbindung muss +-- ein Unix-Socket sein (psql-Variable HOST beginnt mit /, also Aufruf per docker exec im lokalen Container; ein +-- Aufruf über TCP/Host gegen einen Server bricht ab), (3) Abbruch, wenn die Datenbank nach echten Daten aussieht +-- (mehr als 20 Konten oder 20 private Spiele). -- -- Aufruf (lokaler Stack, Docker-Container der Supabase CLI): -- docker exec -i supabase_db_shuffle psql -U postgres -d postgres -X -v confirm_local=yes -v n_games=10000 \ @@ -18,10 +22,20 @@ -- den ganzen DO-Block; deshalb zählt die Ausgabe die Aufrufe über 3 s (Spalte ueber_3s). \set ON_ERROR_STOP on \if :{?confirm_local} +\else + \set confirm_local no +\endif +select :'confirm_local' = 'yes' as confirm_ok, coalesce(:'HOST', '') like '/%' as socket_ok \gset +\if :confirm_ok \else \echo 'Abbruch: mit -v confirm_local=yes aufrufen (nur lokal, siehe Kopfkommentar).' \quit \endif +\if :socket_ok +\else + \echo 'Abbruch: nur per Unix-Socket im lokalen Container (docker exec ... psql), nie über TCP gegen einen Server.' + \quit +\endif \if :{?n_games} \else \set n_games 10000 @@ -205,3 +219,7 @@ select 'alle Fälle zusammen' as fall, count(*) as aufrufe, from perf_tmp.r; rollback; + +-- ANALYZE lässt sich nicht zurückrollen: Statistiken der betroffenen Tabellen neu berechnen +vacuum (analyze) public.games, public.game_alt_titles, public.publishers, public.game_publishers, + public.game_genres, public.game_mechanics, public.genres, public.mechanics; diff --git a/supabase/tests/database/09_catalog_search.test.sql b/supabase/tests/database/09_catalog_search.test.sql index 2d293d6..58c9558 100644 --- a/supabase/tests/database/09_catalog_search.test.sql +++ b/supabase/tests/database/09_catalog_search.test.sql @@ -6,7 +6,7 @@ -- Ohne Suchbegriff wird nur auf Fixture-IDs geprüft (Hilfsfunktion pg_temp.hits blättert durch). -- Die Include-Dateien in ../support/ (\ir) erzeugt supabase/catalog/build_startbestand.ts. begin; -select plan(217); +select plan(222); insert into auth.users (id, email, raw_user_meta_data) values ('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'), @@ -156,6 +156,9 @@ insert into public.games (id, title, visibility) values ('f9000000-0000-0000-0000-000000000092', 'Qxz Zwilling', 'catalog'), ('f9000000-0000-0000-0000-000000000090', 'Qxz Zwilling', 'catalog'), ('f9000000-0000-0000-0000-000000000017', 'Qxz Wanderer', 'catalog'), + -- Stufe 2 (Wortanfang) gegen Stufe 3 (nur Teilzeichenkette "aqxz") + ('f9000000-0000-0000-0000-000000000019', 'Aqxz Bolt', 'catalog'), + ('f9000000-0000-0000-0000-000000000018', 'Das Qxz Bolt', 'catalog'), ('f9000000-0000-0000-0000-000000000080', 'Qxz B', 'catalog'); -- Filter-Fixtures (20-25); 22 ohne jede Angabe, 23 nur max_minutes, 24 nur min_minutes @@ -389,6 +392,15 @@ select results_eq($$select h from pg_temp.hits(p_players := 2::smallint) h order select results_eq($$select h from pg_temp.hits(p_max_minutes := 45::smallint) h order by 1$$, $$values ('f9000000-0000-0000-0000-000000000023'::uuid), ('f9000000-0000-0000-0000-000000000024'), ('f9000000-0000-0000-0000-000000000025')$$, 'KAT-01 Spec-F2: höchstens 45 Minuten; nur max_minutes oder nur min_minutes bekannt zählt, ohne Angabe fehlt'); +select results_eq($$select h from pg_temp.hits('qxz filter', p_age := 8::smallint) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000023'), ('f9000000-0000-0000-0000-000000000024')$$, + 'KAT-01 Kriterium 4: Alter 8 mit Suchbegriff'); +select results_eq($$select h from pg_temp.hits('qxz filter', p_genre_ids := array['f9000000-0000-0000-0000-0000000000e1', 'f9000000-0000-0000-0000-0000000000e2']::uuid[]) h$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid)$$, + 'KAT-01 Spec-F1: zwei Genres mit Suchbegriff: nur Spiele mit beiden'); +select results_eq($$select h from pg_temp.hits('qxz filter', p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d1', 'f9000000-0000-0000-0000-0000000000d2']::uuid[]) h$$, + $$values ('f9000000-0000-0000-0000-000000000021'::uuid)$$, + 'KAT-01 Spec-F1: zwei Mechaniken mit Suchbegriff: nur Spiele mit beiden'); select results_eq($$select h from pg_temp.hits('qxz filter', p_max_minutes := 60::smallint) h order by 1$$, $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000023'), ('f9000000-0000-0000-0000-000000000024'), ('f9000000-0000-0000-0000-000000000025')$$, 'KAT-01 Kriterium 4: höchstens 60 Minuten (Grenze zählt mit) mit Suchbegriff'); @@ -461,6 +473,10 @@ select is((select count(*) from public.search_catalog_games('qxz seite', null, n 'KAT-01 Kriterium 5: hinter dem Ende keine Zeilen (hasMore false)'); select is((select count(*) from public.search_catalog_games('qxz seite')), 30::bigint, 'KAT-01 Kriterium 5: Standard-Seitengröße 30'); select is((select count(*) from public.search_catalog_games('qxz seite', p_limit := 50)), 40::bigint, 'KAT-01 Kriterium 5: Seitengröße bis 50 erlaubt'); +select is((select count(*) from public.search_catalog_games('qxz seite', p_limit := null, p_offset := null)), 30::bigint, 'KAT-01 Kriterium 5: p_limit null gilt als Standard 30'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz bolt')$$, + $$values ('f9000000-0000-0000-0000-000000000018'::uuid, 2), ('f9000000-0000-0000-0000-000000000019', 3)$$, + 'KAT-01 Kriterium 1: Stufe 2 (Wortanfang) kommt vor Stufe 3 (Teilzeichenkette mitten im Wort)'); select results_eq($$select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 10, 0)$$, $$select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 10, 0)$$, 'KAT-01 Kriterium 5: wiederholter Aufruf liefert dieselbe Reihenfolge');