Compare commits
9 commits
21ed0588eb
...
19f2e676b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 19f2e676b0 | |||
|
|
9fa91eb684 | ||
|
|
354206c766 | ||
|
|
c8bbd10d37 | ||
|
|
0cbe813e5b | ||
|
|
729e51620c | ||
|
|
683b5c830e | ||
|
|
976fbe9fe1 | ||
|
|
3bb1c28831 |
18 changed files with 1832 additions and 17 deletions
|
|
@ -28,7 +28,7 @@ Die Pfeile zeigen, welche Tabelle auf welche verweist. Jede Partie (`plays`) hä
|
|||
|
||||
## Tabellen
|
||||
|
||||
R1 braucht 21 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog_sources`, `publishers`, `game_publishers` und `game_reports` aus KAT-05). Jede Tabelle hat einen eigenen Primärschlüssel `id` (uuid, PK) sowie `created_at` und `updated_at`; diese drei Spalten stehen in den Tabellen unten nicht mehr einzeln. `updated_at` setzt ein Datenbank-Trigger bei jeder Änderung, nie der Client. Ein Fremdschlüssel (FK) steht nur dort, wo zwei Tabellen in Beziehung stehen, und heißt `<Zieltabelle>_id`; er speichert die `id` der Zielzeile. Spaltennamen sind Englisch (Konvention aus CLAUDE.md), die Namen der Statuswerte sind mein Vorschlag.
|
||||
R1 braucht 22 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog_sources`, `publishers`, `game_publishers` und `game_reports` aus KAT-05 sowie `game_alt_titles` aus KAT-01). Jede Tabelle hat einen eigenen Primärschlüssel `id` (uuid, PK) sowie `created_at` und `updated_at`; diese drei Spalten stehen in den Tabellen unten nicht mehr einzeln. `updated_at` setzt ein Datenbank-Trigger bei jeder Änderung, nie der Client. Ein Fremdschlüssel (FK) steht nur dort, wo zwei Tabellen in Beziehung stehen, und heißt `<Zieltabelle>_id`; er speichert die `id` der Zielzeile. Spaltennamen sind Englisch (Konvention aus CLAUDE.md), die Namen der Statuswerte sind mein Vorschlag.
|
||||
|
||||
### Konto und Profil
|
||||
|
||||
|
|
@ -42,10 +42,11 @@ R1 braucht 21 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog
|
|||
|
||||
| Tabelle | Spalten | Zweck |
|
||||
| --- | --- | --- |
|
||||
| `games` | `owner_id` (FK → `profiles`, nur bei privaten Spielen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` (optional, eindeutig), `visibility` (`catalog` oder `private`), `hidden_at`, `merged_into_id` (FK → `games`), `manual_fields`, `catalog_source_id` (FK → `catalog_sources`), `source_ref`, `source_url`, `source_synced_at` | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die letzten sieben Spalten gehören der Katalogpflege (KAT-05), siehe unten |
|
||||
| `games` | `owner_id` (FK → `profiles`, nur bei privaten Spielen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` (optional, eindeutig), `visibility` (`catalog` oder `private`), `hidden_at`, `merged_into_id` (FK → `games`), `manual_fields`, `catalog_source_id` (FK → `catalog_sources`), `source_ref`, `source_url`, `source_synced_at`, `title_search` (generiert) | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die sieben Spalten vor `title_search` gehören der Katalogpflege (KAT-05), `title_search` der Suche (KAT-01), siehe unten |
|
||||
| `game_reports` | `owner_id` (FK → `profiles`), `origin` (`user`/`import`), `title`, `publisher_name`, `year`, `link`, `bgg_id`, `provisional_game_id` (FK → `games`), `status` (`open`/`approved`/`rejected`/`merged`), `target_game_id` (FK → `games`), `resolved_at`, `editions_cleared` | Meldungen fehlender Spiele (KAT-05 PR 2c, Anlegen durch Nutzer kommt mit KAT-04); siehe Katalogpflege |
|
||||
| `catalog_sources` | `key`, `name`, `attribution_text`, `attribution_url`, `images_allowed`, `texts_allowed`, `terms_url`, `terms_checked_at` | Datenquellen des Katalogs mit Anzeige-Erlaubnis für Bilder und Texte je Quelle (KAT-05); die Migration legt `bgg` ohne Erlaubnis an |
|
||||
| `publishers`, `game_publishers` | `name` (eindeutig ohne Beachtung der Schreibweise); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) |
|
||||
| `publishers`, `game_publishers` | `name` (eindeutig ohne Beachtung der Schreibweise), `name_search` (generiert); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) |
|
||||
| `game_alt_titles` | `game_id` (FK → `games`), `title`, `locale` (`de`/`en`), `title_search` (generiert) | Alternative Titel eines Spiels mit Sprache (Klärung 3, KAT-01); die Suche findet das Spiel auch darüber |
|
||||
| `game_variants` | `game_id` (FK → `games`), `name`, `year`, `language`, `bgg_id` (optional) | Editionen und Varianten eines Spiels (O4) |
|
||||
| `genres`, `mechanics`, `categories` | `name_de`, `name_en` | Einheitliche Begriffe für Filter und Spieldetails |
|
||||
| `game_genres` | `game_id` (FK → `games`), `genre_id` (FK → `genres`) | Zuordnung Spiel zu Genre; Paar eindeutig |
|
||||
|
|
@ -78,6 +79,7 @@ Jede Tabelle hat Row Level Security (RLS) und eine Policy-Test-Suite (pgTAP). De
|
|||
| `favorite_games`, `bgg_links` | Nur eigene | Nur eigene |
|
||||
| `games`, `game_variants`, Begriffe | Katalog für alle (O6); private Spiele nur der Ersteller | Katalog nur Admin (in R1 nur über Studio/SQL, E10); private Spiele nur der Ersteller, und nur die Spalten laut Spaltenrechten |
|
||||
| `game_reports` | nur der Meldende liest seine Zeilen (Admin-Policy `is_admin()` für R4); Schreiben kein Nutzerrecht | Anlegen ergänzt KAT-04 (#13); Bearbeiten nur über `admin_resolve_report` (Studio) |
|
||||
| `game_alt_titles` | für alle, auch ohne Anmeldung; folgt dem Spiel (alternative Titel eines fremden privaten Spiels sind unsichtbar) | **niemand über die API** (kein Recht, keine Policy); Pflege nur über Studio und das Startbestand-Werkzeug |
|
||||
| `catalog_sources`, `publishers`, `game_publishers`, View `catalog_games` | für alle, auch ohne Anmeldung (`game_publishers` folgt dem Spiel) | Quellen und Verlage nur Admin; `game_publishers` Admin oder Ersteller des privaten Spiels; die View ist nicht beschreibbar |
|
||||
| `collection_items`, `score_sheets`, `score_categories` | Nur eigene | Nur eigene |
|
||||
| `plays` | Ersteller und eingeladene Mitspieler (offen oder bestätigt) | Nur Ersteller |
|
||||
|
|
@ -197,11 +199,12 @@ Diese Struktur ist aus den fertigen Migrationen in `supabase/migrations/` ausgel
|
|||
| `description_locale` | text | nein | | `de` oder `en`, nur zusammen mit `description` |
|
||||
| `hidden_at` | timestamptz | nein | | gesetzt = ausgeblendet |
|
||||
| `merged_into_id` | uuid | nein | | FK → games (`on delete restrict`), nur auf Katalogspiele; nur mit `hidden_at` |
|
||||
| `manual_fields` | text[] | ja | `'{}'` | von Hand gepflegte Felder (Trigger), nur bekannte Schlüssel |
|
||||
| `manual_fields` | text[] | ja | `'{}'` | von Hand gepflegte Felder (Trigger), nur bekannte Schlüssel: `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `genres`, `mechanics`, `categories`, `publishers`, `alt_titles` |
|
||||
| `catalog_source_id` | uuid | nein | | FK → catalog_sources (`on delete restrict`) |
|
||||
| `source_ref` | text | nein | | Kennung in der Quelle; genau dann gesetzt, wenn `catalog_source_id` gesetzt ist |
|
||||
| `source_url` | text | nein | | `https://`, nur mit Quelle |
|
||||
| `source_synced_at` | timestamptz | nein | | letzter Abgleich, nur mit Quelle |
|
||||
| `title_search` | text | nein | generiert | `public.search_normalize(title)`, gespeichert; nicht beschreibbar (KAT-01) |
|
||||
| `created_at` | timestamptz | ja | `now()` | |
|
||||
| `updated_at` | timestamptz | ja | `now()` | |
|
||||
|
||||
|
|
@ -220,7 +223,20 @@ Diese Struktur ist aus den fertigen Migrationen in `supabase/migrations/` ausgel
|
|||
| `terms_checked_at` | date | nein | | |
|
||||
| `created_at`, `updated_at` | timestamptz | ja | `now()` | |
|
||||
|
||||
**`publishers`** hat `id`, `name` (1 bis 200 Zeichen, eindeutig ohne Beachtung der Schreibweise), `created_at`, `updated_at`. **`game_publishers`** verbindet wie die anderen Zuordnungen ein Spiel (`game_id`, FK → games, `on delete cascade`) mit einem Verlag (`publisher_id`, FK → publishers, `on delete cascade`); das Paar ist eindeutig.
|
||||
**`publishers`** hat `id`, `name` (1 bis 200 Zeichen, eindeutig ohne Beachtung der Schreibweise), `name_search` (generiert, `public.search_normalize(name)`), `created_at`, `updated_at`. **`game_publishers`** verbindet wie die anderen Zuordnungen ein Spiel (`game_id`, FK → games, `on delete cascade`) mit einem Verlag (`publisher_id`, FK → publishers, `on delete cascade`); das Paar ist eindeutig.
|
||||
|
||||
**`game_alt_titles`** (KAT-01)
|
||||
|
||||
| Spalte | Typ | Pflicht | Standard | Schlüssel |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `id` | uuid | ja | `gen_random_uuid()` | PK |
|
||||
| `game_id` | uuid | ja | | FK → games (`on delete cascade`) |
|
||||
| `title` | text | ja | | 1 bis 200 Zeichen |
|
||||
| `locale` | text | ja | | `de` oder `en` |
|
||||
| `title_search` | text | ja | generiert | `public.search_normalize(title)`, gespeichert; darf nicht leer sein (ein Titel nur aus Sonderzeichen ist unzulässig) |
|
||||
| `created_at`, `updated_at` | timestamptz | ja | `now()` | |
|
||||
|
||||
Eindeutig ist `(game_id, locale, title_search)`: derselbe Titel in derselben Sprache nur einmal, unabhängig von der Schreibweise; mehrere Titel je Sprache sind erlaubt. Lesbar wie das Spiel; kein Schreibrecht für API-Rollen.
|
||||
|
||||
**`game_variants`**
|
||||
|
||||
|
|
@ -373,3 +389,17 @@ Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle
|
|||
- **Startbestand:** `supabase/catalog/` (CSV, Werkzeug, erzeugte SQL-Dateien); `supabase db reset` spielt Vokabular und Startbestand ein. Der Startbestand enthält nur Fakten und einen eigenen deutschen Kurztext, kein Cover, keine Quelle, keine BGG-Kennung.
|
||||
- **Konto löschen:** Neue Nutzerdaten entstehen nicht; die Zuordnungen privater Spiele (`game_publishers` usw.) werden mit dem Spiel gelöscht.
|
||||
- **API-Änderungen für das Frontend:** neu lesbar für alle: View `catalog_games`, Tabellen `catalog_sources`, `publishers`, `game_publishers`; neu für den Meldenden lesbar: `game_reports`. Das Ändern von `visibility` an einem Spiel ist nicht mehr möglich (P0001 `visibility_immutable`); wer eine Sichtbarkeit mitsendet, muss den alten Wert senden oder die Spalte weglassen. Beim Anlegen und Ändern eines privaten Spiels dürfen Clients keine `id`, `created_at` und `updated_at` mehr mitsenden (42501; die ID kommt per `returning`); ein `toJson()` eines Modells darf nie ungefiltert an `insert`/`update` gehen (Merkpunkt für #3 und #13). Der `is_admin()`-Zweig der Zuordnungs-Policies (`game_*_write`) greift für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` `manual_fields` schreiben muss; ein späterer Admin-Pfad (QUE-08) braucht eigene RPCs. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGame` legt KAT-01 (#3) auf Basis der Spalten von `catalog_games` an.
|
||||
|
||||
## Katalogsuche (KAT-01, Migration `catalog_search`)
|
||||
|
||||
Spec: `docs/specs/3-katalog-suche.md`. Die Suche liest ausschließlich über die RPC `search_catalog_games`, die ihrerseits die View `catalog_games` nutzt.
|
||||
|
||||
- **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`, 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 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.
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
Dieses Dokument beschreibt, wie der Admin (Chris) den Katalog in R1 pflegt: ausschließlich über Supabase Studio bzw. `psql`, es gibt keine Admin-Oberfläche in der App (Klärungen 25/44). Grundlage ist die Spec `docs/specs/2-katalog-daten.md`.
|
||||
|
||||
Stand: PR 2c (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen bearbeiten).
|
||||
Stand: PR 3a (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen bearbeiten, alternative Titel und Suche).
|
||||
|
||||
**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (Abschnitte 3 und 4 in `supabase/tests/database/06_catalog_foundation.test.sql`, Abschnitt 5 in `07_catalog_merge.test.sql`, Abschnitt 6 in `08_game_reports.test.sql`; Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1).
|
||||
**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (Abschnitte 3 und 4 in `supabase/tests/database/06_catalog_foundation.test.sql`, Abschnitt 5 in `07_catalog_merge.test.sql`, Abschnitt 6 in `08_game_reports.test.sql`, Abschnitte 3.1, 5 (Schritt 2b) und 8 in `09_catalog_search.test.sql`; Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1).
|
||||
|
||||
## 1. Zugang
|
||||
|
||||
|
|
@ -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 | reserviert für KAT-01 (`de:Titel;en:Title`), wird ignoriert |
|
||||
| `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.
|
||||
|
|
@ -166,7 +166,37 @@ Regeln:
|
|||
- **Katalogspiele nie löschen, sondern ausblenden** (siehe 4). Zugeordnete Sammlungen, Favoriten und Partien verweisen auf sie.
|
||||
- **`merged_into_id` nie von Hand setzen**; Zusammenführen geht nur über die Funktion `admin_merge_games` (siehe 5). Der Trigger lehnt einen Verweis auf ein privates Spiel ab (`merged_into_not_catalog`).
|
||||
- Private Spiele von Nutzern im Studio nicht anfassen.
|
||||
- Zulässige Schlüssel in `manual_fields`: `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `genres`, `mechanics`, `categories`, `publishers`.
|
||||
- Zulässige Schlüssel in `manual_fields`: `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `genres`, `mechanics`, `categories`, `publishers`, `alt_titles`.
|
||||
|
||||
### 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. **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):
|
||||
|
||||
```sql
|
||||
insert into public.game_alt_titles (game_id, locale, title)
|
||||
values ('7a1b2c3d-0000-4000-8000-000000000001', 'de', 'Beispielspiel Deutsch'),
|
||||
('7a1b2c3d-0000-4000-8000-000000000001', 'en', 'Example game')
|
||||
on conflict (game_id, locale, title_search) do update set title = excluded.title
|
||||
where public.game_alt_titles.title is distinct from excluded.title;
|
||||
```
|
||||
|
||||
**Ansehen:**
|
||||
|
||||
```sql
|
||||
select locale, title from public.game_alt_titles
|
||||
where game_id = '7a1b2c3d-0000-4000-8000-000000000001' order by locale, title;
|
||||
```
|
||||
|
||||
**Löschen:**
|
||||
|
||||
```sql
|
||||
delete from public.game_alt_titles
|
||||
where game_id = '7a1b2c3d-0000-4000-8000-000000000001' and locale = 'en' and title = 'Example game';
|
||||
```
|
||||
|
||||
Die Suche normalisiert Titel selbst (Groß-/Kleinschreibung, Akzente, Sonderzeichen, Marken- und Schutzzeichen); ein alternativer Titel muss deshalb nicht in mehreren Schreibweisen angelegt werden. Ein Suchwort „kaese“ findet „Käse“ in R1 nicht.
|
||||
|
||||
## 4. Ausblenden und wieder einblenden
|
||||
|
||||
|
|
@ -233,6 +263,23 @@ join public.collection_items t on t.owner_id = s.owner_id and t.game_id = '7a1b2
|
|||
where s.game_id = '7a1b2c3d-0000-4000-8000-000000000002';
|
||||
```
|
||||
|
||||
**Schritt 2b: alternative Titel der Quelle am Ziel ergänzen.** Beim Zusammenführen werden `game_alt_titles` **nicht** umgehängt, sie bleiben am ausgeblendeten Quell-Eintrag (Spec-F6 A); die Suche zeigt die Quelle nicht mehr. Damit das Ziel weiter über die Namen der Quelle gefunden wird, vor dem Zusammenführen ansehen und übernehmen:
|
||||
|
||||
```sql
|
||||
select locale, title from public.game_alt_titles
|
||||
where game_id = '7a1b2c3d-0000-4000-8000-000000000002' order by locale, title;
|
||||
```
|
||||
|
||||
```sql
|
||||
insert into public.game_alt_titles (game_id, locale, title)
|
||||
select '7a1b2c3d-0000-4000-8000-000000000003', a.locale, a.title
|
||||
from public.game_alt_titles a
|
||||
where a.game_id = '7a1b2c3d-0000-4000-8000-000000000002'
|
||||
on conflict (game_id, locale, title_search) do nothing;
|
||||
```
|
||||
|
||||
Der Haupttitel der Quelle hat keine Sprache und wird nicht automatisch übernommen; soll er als alternativer Titel am Ziel stehen, legt man ihn mit dem Baustein aus 3.1 mit der passenden Sprache an.
|
||||
|
||||
**Schritt 3: Zusammenführen.** Der Baustein steht wie überall ohne `begin`/`commit`; er wird in einer Transaktion ausgeführt. Bei einem Fehler wird nichts geändert:
|
||||
|
||||
```sql
|
||||
|
|
@ -499,3 +546,27 @@ update auth.users set raw_app_meta_data = raw_app_meta_data || '{"role":"admin"}
|
|||
**Nicht in R1 ausführen und nicht in pgTAP getestet.** Nie `user_metadata` dafür verwenden (vom Client änderbar).
|
||||
|
||||
Hinweis für QUE-08 (R4): Der `is_admin()`-Zweig der Policies `game_*_write` (Genre, Mechanik, Kategorie, Verlag) funktioniert für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` (security invoker) `games.manual_fields` schreiben muss und `authenticated` dafür kein Spaltenrecht hat. Ein Admin-Pfad über die API braucht eigene RPCs (security definer).
|
||||
|
||||
## 8. Suche: Normalisierung und Betrieb
|
||||
|
||||
### 8.1 Nach einem Image-Upgrade: Normalisierung prüfen
|
||||
|
||||
Die Suche speichert normalisierte Titel in den generierten Spalten `games.title_search`, `game_alt_titles.title_search` und `publishers.name_search` (Funktion `public.search_normalize`). Die Funktion ruft `unaccent` mit der Datei `unaccent.rules` des Datenbank-Images auf und ist als `immutable` deklariert. Ändert ein **Image-Upgrade** diese Datei (oder wird die Funktion geändert), weichen die gespeicherten Werte von dem ab, was die Funktion heute berechnet; Suchen treffen dann nicht mehr. **Nach jedem Image-Upgrade und vor dem Release** diese Prüfabfrage ausführen; jede Zeile muss `0` zeigen:
|
||||
|
||||
```sql
|
||||
select 'games' as tabelle, count(*) as abweichend from public.games
|
||||
where title_search is distinct from public.search_normalize(title)
|
||||
union all select 'game_alt_titles', count(*) from public.game_alt_titles
|
||||
where title_search is distinct from public.search_normalize(title)
|
||||
union all select 'publishers', count(*) from public.publishers
|
||||
where name_search is distinct from public.search_normalize(name);
|
||||
```
|
||||
|
||||
Bei einer Abweichung die betroffenen Spalten **per neuer Migration** entfernen und neu anlegen (gleiche Definition, `generated always as (public.search_normalize(…)) stored`, dazu den Index neu anlegen); nie von Hand im Studio. Dasselbe gilt, wenn `search_normalize` selbst geändert wird.
|
||||
|
||||
### 8.2 Betrieb: Zeitlimits, Mengenbegrenzung und Protokolle
|
||||
|
||||
- **Ohne Anmeldung aufrufbar:** `search_catalog_games` ist für `anon` ausführbar. Die Eingabegrenzen der Funktion (100 Zeichen, 10 Wörter, höchstens 20 IDs je Filter, Seitengröße 50) begrenzen die Last je Aufruf; zusätzlich gelten die `statement_timeout`-Werte der Rollen `anon` und `authenticated` (lokal 3 s und 8 s; im Self-Hosting prüfen, `select rolname, rolconfig from pg_roles where rolname in ('anon', 'authenticated')`). Ein `set statement_timeout` an der Funktion wirkt nicht.
|
||||
- **Mengenbegrenzung je IP (Entscheidung vor dem Go-live, Aufgabe D1):** Das eigentliche Risiko sind viele parallele Aufrufe ohne Anmeldung. Für R1.1 ist eine Begrenzung kein Muss; vor dem Go-live entscheidet devops, ob das Kong-Plugin `rate-limiting` auf `/rest/v1/rpc/search_catalog_games` oder `limit_req` im vorgelagerten Proxy gesetzt wird, und trägt das in das Hosting-Runbook ein.
|
||||
- **Suchbegriffe nicht protokollieren (QUE-04):** Die App sendet den Suchbegriff per `POST` im Anfragekörper (nicht in der URL); PostgREST reicht ihn als JSON-Parameter `$1` an die Datenbank. Drei Einstellungen müssen **zusammen** geprüft werden, weil sie voneinander abhängen: (1) `log_parameter_max_length_on_error = 0`, sonst stünde der Begriff im Fehlerprotokoll (Fehler wie `search_query_too_long` erscheinen lokal nur mit dem Platzhalter `$1`); (2) `log_min_duration_statement = -1`, `log_statement` höchstens `ddl`, `pgaudit.log = none`; (3) `auto_explain.log_min_duration` (lokal 10 s, mit `auto_explain.log_parameter_max_length = -1`, also **mit** Parameterwerten) muss **größer** sein als das längste `statement_timeout` der Rollen `anon` und `authenticated` (lokal 3 s und 8 s): Eine Suche, die das Zeitlimit erreicht, wird abgebrochen und erzeugt keine auto_explain-Ausgabe. Wird das Zeitlimit von `authenticated` über 10 s angehoben (oder `auto_explain.log_min_duration` gesenkt), landen Suchbegriffe im Protokoll. Prüfung auf Staging/Produktion: `select name, setting from pg_settings where name in ('log_min_duration_statement', 'log_statement', 'log_parameter_max_length', 'log_parameter_max_length_on_error', 'auto_explain.log_min_duration', 'pgaudit.log', 'pgaudit.log_parameter')`. Lokal bestätigt (Aufruf per `curl` gegen Kong, Datenbank- und PostgREST-Protokoll enthalten den Begriff nicht); Hetzner ist **nicht** geprüft.
|
||||
- **Erweiterungen:** `select * from pg_available_extensions where name in ('pg_trgm', 'unaccent')` muss auf Staging/Produktion beide Zeilen liefern, bevor die Migration `catalog_search` eingespielt wird.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
72
docs/specs/3-katalog-suche-umsetzung.md
Normal file
72
docs/specs/3-katalog-suche-umsetzung.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -3,8 +3,9 @@
|
|||
## Inhalt
|
||||
| Pfad | Zweck |
|
||||
|---|---|
|
||||
| `migrations/` | Schema, Trigger und RLS-Policies für R1 (21 Tabellen, 3 Views). Nur additiv ändern, nie bestehende Dateien editieren. |
|
||||
| `migrations/` | Schema, Trigger und RLS-Policies für R1 (22 Tabellen, 3 Views). Nur additiv ändern, nie bestehende Dateien editieren. |
|
||||
| `tests/database/` | pgTAP-Tests: Struktur-Regeln und Zugriffsrechte je Bereich. |
|
||||
| `perf/` | Messskript für die Katalogsuche (kein Test, läuft nicht in `supabase test db`). |
|
||||
| `templates/` | Auth-Mailvorlagen (HTML, Go-Template) für Bestätigung und Passwort-Reset. |
|
||||
| `seed.sql` | Beispieldaten nur für die lokale Entwicklung. |
|
||||
| `catalog/` | Katalog-Vokabular und Startbestand (CSV, Werkzeug `build_startbestand.ts`, erzeugte SQL-Dateien), siehe unten. |
|
||||
|
|
@ -42,6 +43,12 @@ supabase test db # alle pgTAP-Tests ausführen
|
|||
- Ein Pfad in `sql_paths`, der nicht existiert, wird von der CLI nur mit Warnung übersprungen (kein Fehler). Genauere CLI-Ergebnisse: `docs/katalog-pflege.md`, Abschnitt 2.7.
|
||||
- pgTAP-Tests, die Include-Dateien brauchen, nutzen `\ir ../support/…`; die Dateien in `tests/support/` sind erzeugt (gleiches Werkzeug) und keine Tests.
|
||||
|
||||
## Katalogsuche (KAT-01)
|
||||
- `public.search_normalize(text)` (Normalisierung für Suche und Titelvergleich) und die RPC `public.search_catalog_games(…)` sind **für API-Rollen ausführbar** (`search_normalize`: `anon`, `authenticated`, `service_role`, weil generierte Spalten mit den Rechten des Schreibenden rechnen; `search_catalog_games`: `anon`, `authenticated`). Beides ist die Ausnahme von der Regel „Funktionen sind für niemanden aufrufbar“; die Rechte prüft `tests/database/09_catalog_search.test.sql`.
|
||||
- Alternative Titel (`game_alt_titles`) sind für API-Rollen nur lesbar; Pflege über Studio (`docs/katalog-pflege.md`, Abschnitt 3.1) und die CSV-Spalte `alt_titles` (`de:Titel;en:Title`, `;` im Titel nicht erlaubt).
|
||||
- Nach einem Image-Upgrade der Datenbank die Normalisierung prüfen (`docs/katalog-pflege.md`, Abschnitt 8).
|
||||
- Messskript für das Zeitbudget der Suche (nur lokal, nie gegen Staging/Produktion, bewusst nicht unter `tests/`): `perf/catalog_search_perf.sql`, Aufruf im Kopfkommentar.
|
||||
|
||||
## Regeln
|
||||
- Jede Tabelle hat `id` (uuid, PK), `created_at`, `updated_at` und Row Level Security.
|
||||
- `updated_at` setzt ein Trigger; `id` und `created_at` sind nach dem Anlegen unveränderlich.
|
||||
|
|
|
|||
|
|
@ -257,6 +257,12 @@ export interface Game {
|
|||
mechanics: string[]; // name_de
|
||||
description: string;
|
||||
hidden: boolean;
|
||||
alt_titles: AltTitle[];
|
||||
}
|
||||
|
||||
export interface AltTitle {
|
||||
locale: "de" | "en";
|
||||
title: string;
|
||||
}
|
||||
|
||||
const REQUIRED_COLUMNS = [
|
||||
|
|
@ -270,6 +276,68 @@ function splitList(value: string): string[] {
|
|||
return value.split(";").map((s) => s.trim()).filter((s) => s !== "");
|
||||
}
|
||||
|
||||
// Annäherung an public.search_normalize (Marken- und Schutzzeichen entfernen, Akzente/Umlaute entfernen,
|
||||
// Kleinschreibung, alles außer a-z0-9 zu einem Leerzeichen). Maßgeblich bleibt die Datenbank
|
||||
// (Unique-Constraint game_alt_titles_game_locale_title_key); hier nur für frühe Fehlermeldungen.
|
||||
const FOLD: Record<string, string> = { "ß": "ss", "æ": "ae", "œ": "oe", "ø": "o", "đ": "d", "ł": "l", "ð": "d", "þ": "th" };
|
||||
export function approxNormalize(s: string): string {
|
||||
return s
|
||||
.replace(/[®©™℗℠]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[ßæœøđłðþ]/g, (c) => FOLD[c])
|
||||
.normalize("NFKD")
|
||||
.replace(/\p{M}+/gu, "")
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Spalte alt_titles: "de:Titel;en:Title" (Spec #3, 4.3). Sprache und Titel am ersten ":" getrennt.
|
||||
// ";" ist in Titeln nicht erlaubt (kein Escaping); ein Titel mit ";" fällt als Eintrag ohne passende Sprache auf.
|
||||
export function parseAltTitles(raw: string, mainTitle: string, fail: (msg: string) => void): AltTitle[] {
|
||||
const out: AltTitle[] = [];
|
||||
const seen = new Set<string>();
|
||||
const mainNorm = approxNormalize(mainTitle);
|
||||
const semicolonHint = '(";" ist in alternativen Titeln nicht erlaubt, Titel dieser Art im Studio pflegen)';
|
||||
for (const entry of splitList(raw)) {
|
||||
const colon = entry.indexOf(":");
|
||||
if (colon < 0) {
|
||||
fail(`alt_titles: Eintrag "${entry.slice(0, 40)}" hat kein ":" (Format de:Titel;en:Title) ${semicolonHint}`);
|
||||
continue;
|
||||
}
|
||||
const locale = entry.slice(0, colon).trim();
|
||||
const title = entry.slice(colon + 1).trim();
|
||||
if (locale !== "de" && locale !== "en") {
|
||||
fail(`alt_titles: Sprache "${locale.slice(0, 20)}" in "${entry.slice(0, 40)}" muss "de" oder "en" sein ${semicolonHint}`);
|
||||
continue;
|
||||
}
|
||||
if (title === "") {
|
||||
fail(`alt_titles: leerer Titel bei Sprache "${locale}"`);
|
||||
continue;
|
||||
}
|
||||
if (title.length > 200) {
|
||||
fail(`alt_titles: Titel "${title.slice(0, 30)}…" länger als 200 Zeichen`);
|
||||
continue;
|
||||
}
|
||||
const norm = approxNormalize(title);
|
||||
if (norm === "") {
|
||||
fail(`alt_titles: Titel "${title}" besteht nur aus Sonderzeichen`);
|
||||
continue;
|
||||
}
|
||||
if (norm === mainNorm) {
|
||||
fail(`alt_titles: "${title}" ist gleich dem Haupttitel (ohne Beachtung von Schreibweise und Sonderzeichen)`);
|
||||
continue;
|
||||
}
|
||||
const k = `${locale}:${norm}`;
|
||||
if (seen.has(k)) {
|
||||
fail(`alt_titles: Titel "${title}" doppelt (Sprache ${locale})`);
|
||||
continue;
|
||||
}
|
||||
seen.add(k);
|
||||
out.push({ locale, title });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function parseStartbestand(
|
||||
text: string,
|
||||
vocabulary: VocabEntry[],
|
||||
|
|
@ -363,10 +431,13 @@ export async function parseStartbestand(
|
|||
else if (!allKeys.has(base)) fail(`base_game_key "${base}" hat keinen passenden key (SB7)`);
|
||||
}
|
||||
|
||||
const alt_titles = parseAltTitles(cells.alt_titles ?? "", title, fail);
|
||||
|
||||
if (problems.length === before) {
|
||||
games.push({
|
||||
key, id: await uuidV5(`game:${key}`), title, publishers, year, min_players, max_players,
|
||||
min_minutes, max_minutes, min_age, genres, mechanics, description, hidden: hiddenRaw === "ja",
|
||||
alt_titles,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -515,6 +586,28 @@ export function generateStartbestandSql(games: Game[]): string {
|
|||
reconcile("Genre", "game_genres", "genre_id", "genres", "t.name_de = w.term", (g) => g.genres);
|
||||
reconcile("Mechanik", "game_mechanics", "mechanic_id", "mechanics", "t.name_de = w.term", (g) => g.mechanics);
|
||||
reconcile("Verlag", "game_publishers", "publisher_id", "publishers", "lower(t.name) = lower(w.term)", (g) => g.publishers);
|
||||
|
||||
// 5. Alternative Titel (Spec #3, 4.3): Abgleich je Spiel über die normalisierte Form. Unveränderte
|
||||
// Zeilen bleiben unberührt (updated_at und manual_fields stabil); Spiele ohne alt_titles verlieren
|
||||
// früher gesetzte Titel. Keine abgeleitete ID, Konfliktziel ist die Unique-Constraint.
|
||||
const altRows = games.flatMap((g) => g.alt_titles.map((a) => ` (${q(g.id)}::uuid, ${q(a.locale)}, ${q(a.title)})`));
|
||||
out.push("-- Alternative Titel: fehlende anlegen, geänderte Schreibweise aktualisieren, nicht mehr gewünschte löschen");
|
||||
if (altRows.length === 0) {
|
||||
out.push(`delete from public.game_alt_titles a where a.game_id = any(array[${ids}]::uuid[]);`, "");
|
||||
} else {
|
||||
out.push(`with want(game_id, locale, title) as (values`);
|
||||
out.push(altRows.join(",\n"));
|
||||
out.push(`), removed as (`);
|
||||
out.push(` delete from public.game_alt_titles a`);
|
||||
out.push(` where a.game_id = any(array[${ids}]::uuid[])`);
|
||||
out.push(` and not exists (select 1 from want w where w.game_id = a.game_id and w.locale = a.locale`);
|
||||
out.push(` and public.search_normalize(w.title) = a.title_search)`);
|
||||
out.push(`)`);
|
||||
out.push(`insert into public.game_alt_titles (game_id, locale, title)`);
|
||||
out.push(`select game_id, locale, title from want`);
|
||||
out.push(`on conflict (game_id, locale, title_search) do update set title = excluded.title`);
|
||||
out.push(`where public.game_alt_titles.title is distinct from excluded.title;`, "");
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
|
|
@ -559,6 +652,9 @@ export function generateChecksQuery(startbestandIds: string[], vocabulary: Vocab
|
|||
` select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean`,
|
||||
` from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id)`,
|
||||
` + (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x`,
|
||||
` union all`,
|
||||
` select 7, 'Info-Alt', 'Startbestandsspiele mit alternativen Titeln', '-', n, null::boolean`,
|
||||
` from (select count(distinct a.game_id) as n from public.game_alt_titles a join sb on sb.id = a.game_id) x`,
|
||||
`) r`,
|
||||
`order by ord`,
|
||||
].join("\n");
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
BuildError,
|
||||
generateStartbestandSql,
|
||||
NAMESPACE,
|
||||
approxNormalize,
|
||||
parseAltTitles,
|
||||
parseCsv,
|
||||
parseKeys,
|
||||
parseStartbestand,
|
||||
|
|
@ -250,9 +252,91 @@ Deno.test("hidden = ja erzeugt hidden_at, leer fasst hidden_at nicht an", async
|
|||
assert.doesNotMatch(plain, /update public\.games set/);
|
||||
});
|
||||
|
||||
Deno.test("facts_source, alt_titles und base_game_key landen nicht in der SQL", async () => {
|
||||
const sql = await sqlFor(row({ facts_source: "GEHEIMER-NACHWEIS", alt_titles: "de:Alt", base_game_key: "" }));
|
||||
assert.doesNotMatch(sql, /GEHEIMER-NACHWEIS|alt_titles|base_game_key|facts_source/);
|
||||
Deno.test("facts_source und base_game_key landen nicht in der SQL", async () => {
|
||||
const sql = await sqlFor(row({ facts_source: "GEHEIMER-NACHWEIS", base_game_key: "" }));
|
||||
assert.doesNotMatch(sql, /GEHEIMER-NACHWEIS|base_game_key|facts_source/);
|
||||
});
|
||||
|
||||
// ---- alt_titles (KAT-01, Spec #3 4.3) --------------------------------------------------------
|
||||
function altProblems(raw: string, main = "Testspiel A"): string[] {
|
||||
const problems: string[] = [];
|
||||
parseAltTitles(raw, main, (m) => problems.push(m));
|
||||
return problems;
|
||||
}
|
||||
|
||||
Deno.test("alt_titles: Format de:Titel;en:Title, Leerraum am Rand wird entfernt, ':' im Titel ist erlaubt", () => {
|
||||
const problems: string[] = [];
|
||||
const parsed = parseAltTitles(" de: Catan: Das Duell ; en:Test Duel ", "Testspiel A", (m) => problems.push(m));
|
||||
assert.deepEqual(problems, []);
|
||||
assert.deepEqual(parsed, [{ locale: "de", title: "Catan: Das Duell" }, { locale: "en", title: "Test Duel" }]);
|
||||
assert.deepEqual(parseAltTitles("", "Testspiel A", () => assert.fail("kein Fehler erwartet")), []);
|
||||
});
|
||||
|
||||
Deno.test("alt_titles: Sprache, fehlender Doppelpunkt, leerer und zu langer Titel werden abgelehnt", () => {
|
||||
assert.match(altProblems("fr:Titre").join("\n"), /Sprache "fr".*"de" oder "en"/);
|
||||
assert.match(altProblems("Nur ein Titel").join("\n"), /kein ":"/);
|
||||
assert.match(altProblems("de: ").join("\n"), /leerer Titel/);
|
||||
assert.match(altProblems("de:" + "x".repeat(201)).join("\n"), /länger als 200 Zeichen/);
|
||||
assert.equal(altProblems("de:" + "x".repeat(200)).length, 0);
|
||||
assert.match(altProblems("de:!!!").join("\n"), /nur aus Sonderzeichen/);
|
||||
});
|
||||
|
||||
Deno.test("alt_titles: Duplikat in derselben Sprache und Titel gleich dem Haupttitel werden abgelehnt", () => {
|
||||
assert.match(altProblems("de:Café;de:CAFE").join("\n"), /doppelt \(Sprache de\)/);
|
||||
assert.equal(altProblems("de:Café;en:Café").length, 0, "gleicher Titel in anderer Sprache ist zulässig");
|
||||
assert.match(altProblems("de:testspiel-a").join("\n"), /gleich dem Haupttitel/);
|
||||
assert.match(altProblems("en:CATAN®", "Catan").join("\n"), /gleich dem Haupttitel/);
|
||||
});
|
||||
|
||||
Deno.test("alt_titles: ';' im Titel ergibt einen Fehler mit Zeilennummer und Hinweis", async () => {
|
||||
await rejects(
|
||||
csv(row(), row({ key: "testspiel-b", title: "Testspiel B", alt_titles: "de:Catan;Das Duell" })),
|
||||
/startbestand\.csv:3: alt_titles: Eintrag "Das Duell" hat kein ":".*";" ist in alternativen Titeln nicht erlaubt/,
|
||||
);
|
||||
await rejects(
|
||||
csv(row({ alt_titles: "de:Eins;Zwei: Drei" })),
|
||||
/startbestand\.csv:2: alt_titles: Sprache "Zwei" .*";" ist in alternativen Titeln nicht erlaubt/,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("alt_titles: Fehler anderer Art nennen die Zeilennummer", async () => {
|
||||
await rejects(csv(row(), row({ key: "testspiel-b", alt_titles: "fr:X" })), /startbestand\.csv:3: alt_titles: Sprache "fr"/);
|
||||
});
|
||||
|
||||
Deno.test("approxNormalize: Marken- und Schutzzeichen, Akzente, ß, Æ, Sonderzeichen", () => {
|
||||
assert.equal(approxNormalize("CATAN®"), "catan");
|
||||
assert.equal(approxNormalize("Spiel™Ding"), "spielding"); // Schutzzeichen werden entfernt, nicht zum Trenner (wie search_normalize)
|
||||
assert.equal(approxNormalize("Café Größe"), "cafe grosse");
|
||||
assert.equal(approxNormalize("Æon’s End"), "aeon s end");
|
||||
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 () => {
|
||||
const sql = await sqlFor(row({ alt_titles: "de:Alter Titel;en:Old 'Title'" }));
|
||||
assert.match(sql, /with want\(game_id, locale, title\) as \(values/);
|
||||
assert.match(sql, /'de', 'Alter Titel'\)/);
|
||||
assert.match(sql, /'en', 'Old ''Title'''\)/); // Apostroph verdoppelt
|
||||
assert.match(sql, /delete from public\.game_alt_titles a/);
|
||||
assert.match(sql, /public\.search_normalize\(w\.title\) = a\.title_search/);
|
||||
assert.match(sql, /on conflict \(game_id, locale, title_search\) do update set title = excluded\.title/);
|
||||
assert.match(sql, /where public\.game_alt_titles\.title is distinct from excluded\.title;/);
|
||||
assert.doesNotMatch(sql, /game_alt_titles \(id/); // keine abgeleitete ID
|
||||
});
|
||||
|
||||
Deno.test("SQL alt_titles: ohne alternative Titel nur das Löschen, nie ein leeres values", async () => {
|
||||
const sql = await sqlFor(row());
|
||||
assert.match(sql, /delete from public\.game_alt_titles a where a\.game_id = any\(array\['/);
|
||||
assert.doesNotMatch(sql, /with want\(game_id, locale, title\)/);
|
||||
assert.doesNotMatch(sql, /insert into public\.game_alt_titles/);
|
||||
});
|
||||
|
||||
Deno.test("checks.sql: Informationszeile zu Startbestandsspielen mit alternativen Titeln", async () => {
|
||||
const out = await build({ vocabularyCsv: VOCAB, startbestandCsv: csv(row()), previousKeysCsv: "key,uuid\n" });
|
||||
assert.match(out["checks.sql"], /'Info-Alt', 'Startbestandsspiele mit alternativen Titeln'/);
|
||||
assert.match(out["checks.sql"], /count\(distinct a\.game_id\) as n from public\.game_alt_titles a join sb on sb\.id = a\.game_id/);
|
||||
});
|
||||
|
||||
Deno.test("SQL: Apostroph im Text wird verdoppelt, Upsert nutzt is distinct from, Zuordnungen werden abgeglichen", async () => {
|
||||
|
|
|
|||
|
|
@ -82,5 +82,8 @@ from (
|
|||
select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean
|
||||
from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id)
|
||||
+ (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x
|
||||
union all
|
||||
select 7, 'Info-Alt', 'Startbestandsspiele mit alternativen Titeln', '-', n, null::boolean
|
||||
from (select count(distinct a.game_id) as n from public.game_alt_titles a join sb on sb.id = a.game_id) x
|
||||
) r
|
||||
order by ord;
|
||||
|
|
|
|||
2
supabase/catalog/testdata/startbestand.csv
vendored
2
supabase/catalog/testdata/startbestand.csv
vendored
|
|
@ -1,4 +1,4 @@
|
|||
key,title,publishers,year,min_players,max_players,min_minutes,max_minutes,min_age,genres,mechanics,description,description_locale,hidden,facts_source,alt_titles,base_game_key
|
||||
testspiel-a,Testspiel A,Testverlag,2020,2,4,30,60,8,Testgenre A;Testgenre B,Testmechanik A,Fiktives Testspiel A. Nur für automatische Tests.,de,,Testdaten,,
|
||||
testspiel-b,Testspiel B,Testverlag;Zweiter Testverlag,2021,1,5,10,20,6,Testgenre A,Testmechanik A;Testmechanik B,"Fiktives Testspiel B, mit Komma.",de,,Testdaten,,testspiel-a
|
||||
testspiel-b,Testspiel B,Testverlag;Zweiter Testverlag,2021,1,5,10,20,6,Testgenre A,Testmechanik A;Testmechanik B,"Fiktives Testspiel B, mit Komma.",de,,Testdaten,de:Testspiel Bravo;en:Test Game Bravo,testspiel-a
|
||||
testspiel-c,Testspiel C: Wo's lang geht,TESTVERLAG,1999,3,3,45,45,12,Testgenre B,Testmechanik B,Fiktives Testspiel C mit Apostroph im Titel.,de,ja,Testdaten,,
|
||||
|
|
|
|||
|
341
supabase/migrations/20261103235947_catalog_search.sql
Normal file
341
supabase/migrations/20261103235947_catalog_search.sql
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
-- KAT-01 / PR 3a: Katalogsuche (Spec docs/specs/3-katalog-suche.md, Abschnitt 3.2 und 4.2).
|
||||
-- Nur additiv. Enthält:
|
||||
-- pg_trgm, unaccent Erweiterungen im Schema extensions
|
||||
-- search_normalize(text) Basisfunktion für alle normalisierten Titel (auch für #13)
|
||||
-- title_search, name_search generierte Spalten an games, publishers und game_alt_titles + Trigramm-Indizes
|
||||
-- game_alt_titles alternative Titel mit Sprache (Klärung 3); nur lesbar für API-Rollen
|
||||
-- games_manual_fields_check vollständige Schlüsselliste von main plus 'alt_titles'
|
||||
-- search_catalog_games RPC für die Suche (Vertrag: Spec 4.2)
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 1. Erweiterungen (Bestandteil des Supabase-Postgres-Images)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
create extension if not exists pg_trgm with schema extensions;
|
||||
create extension if not exists unaccent with schema extensions;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 2. search_normalize: die einzige Basisfunktion für normalisierte Titel (E3)
|
||||
-- Schritte: Marken- und Schutzzeichen entfernen, Akzente/Umlaute entfernen, Kleinschreibung, alles
|
||||
-- außer a-z/0-9 zu einem Leerzeichen, Ränder kürzen, leer = null.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
create function public.search_normalize(p text)
|
||||
returns text
|
||||
language sql
|
||||
immutable
|
||||
strict
|
||||
parallel safe
|
||||
set search_path = ''
|
||||
as $$
|
||||
select nullif(
|
||||
btrim(
|
||||
regexp_replace(
|
||||
lower(extensions.unaccent('extensions.unaccent'::regdictionary, regexp_replace(p, '[®©™℗℠]', '', 'g'))),
|
||||
'[^a-z0-9]+', ' ', 'g')),
|
||||
'')
|
||||
$$;
|
||||
|
||||
comment on function public.search_normalize(text) is
|
||||
'Normalisierung für Suche und Titelvergleich (KAT-01, gemeinsame Basis mit KAT-04). '
|
||||
'Rein rechnend, ohne Tabellenzugriff: deshalb für anon, authenticated und service_role ausführbar '
|
||||
'(generierte Spalten und security-invoker-Funktionen rechnen mit den Rechten des Aufrufers). '
|
||||
'immutable ist eine Zusicherung: unaccent selbst ist nur stable; der Wrapper nennt das Wörterbuch ausdrücklich. '
|
||||
'Ändert sich diese Funktion oder die Datei unaccent.rules (Image-Upgrade), müssen die generierten Spalten '
|
||||
'games.title_search, game_alt_titles.title_search und publishers.name_search per Migration neu angelegt werden '
|
||||
'(Prüfabfrage: docs/katalog-pflege.md, Abschnitt Image-Upgrade). Artikel werden hier nie entfernt.';
|
||||
|
||||
-- Rechte-Regel: zuerst allen entziehen, dann nur die vorgesehenen Grants.
|
||||
revoke execute on function public.search_normalize(text) from public, anon, authenticated, service_role;
|
||||
grant execute on function public.search_normalize(text) to anon, authenticated, service_role;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 3. Generierte Spalten und Trigramm-Indizes
|
||||
-- Die Indizes sind keine Zusage für Geschwindigkeit: Bei 2-Zeichen-Wörtern, der Regel ohne Leerzeichen
|
||||
-- und den RLS-Prüfungen für anon werden sie weitgehend nicht genutzt (Messung: supabase/perf/).
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
alter table public.games
|
||||
add column title_search text generated always as (public.search_normalize(title)) stored;
|
||||
alter table public.publishers
|
||||
add column name_search text generated always as (public.search_normalize(name)) stored;
|
||||
|
||||
-- Der Partial-Index passt nur zu Abfragen mit genau den Bedingungen der View catalog_games.
|
||||
create index games_title_search_trgm_idx on public.games
|
||||
using gin (title_search extensions.gin_trgm_ops)
|
||||
where visibility = 'catalog' and hidden_at is null and merged_into_id is null;
|
||||
create index publishers_name_search_trgm_idx on public.publishers
|
||||
using gin (name_search extensions.gin_trgm_ops);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 4. Alternative Titel (Klärung 3, E2). Pflege nur über Studio und das Startbestand-Werkzeug.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
create table public.game_alt_titles (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
game_id uuid not null references public.games (id) on delete cascade,
|
||||
title text not null check (char_length(title) between 1 and 200),
|
||||
locale text not null check (locale in ('de', 'en')),
|
||||
title_search text generated always as (public.search_normalize(title)) stored
|
||||
constraint game_alt_titles_title_search_check check (title_search is not null),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
-- Konfliktziel des Startbestand-Werkzeugs; derselbe Titel in derselben Sprache nur einmal.
|
||||
constraint game_alt_titles_game_locale_title_key unique (game_id, locale, title_search)
|
||||
);
|
||||
|
||||
create trigger set_updated_at before update on public.game_alt_titles
|
||||
for each row execute function public.set_updated_at();
|
||||
create trigger game_links_mark_manual after insert or update or delete on public.game_alt_titles
|
||||
for each row execute function public.game_links_mark_manual('alt_titles');
|
||||
|
||||
create index game_alt_titles_title_search_trgm_idx on public.game_alt_titles
|
||||
using gin (title_search extensions.gin_trgm_ops);
|
||||
|
||||
alter table public.game_alt_titles enable row level security;
|
||||
revoke all on public.game_alt_titles from anon, authenticated;
|
||||
grant select on public.game_alt_titles to anon, authenticated;
|
||||
|
||||
-- Lesen: wer das Spiel sehen darf (Muster game_publishers_select). Schreiben: keine Policy, kein Recht.
|
||||
create policy game_alt_titles_select on public.game_alt_titles
|
||||
for select to anon, authenticated
|
||||
using (exists (select 1 from public.games g where g.id = game_id));
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 5. manual_fields: vollständige Liste von main (Migration catalog_foundation) plus 'alt_titles'
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
alter table public.games
|
||||
drop constraint games_manual_fields_check,
|
||||
add constraint games_manual_fields_check
|
||||
check (manual_fields <@ array['title', 'description', 'description_locale', 'year',
|
||||
'min_players', 'max_players', 'min_minutes', 'max_minutes', 'min_age', 'image_path',
|
||||
'genres', 'mechanics', 'categories', 'publishers', 'alt_titles']::text[]);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 6. RPC search_catalog_games (Vertrag zwischen 3a und 3b: Spec 4.2)
|
||||
-- security invoker: Für den Aufrufer (auch anon) gelten die Policies von games, catalog_sources,
|
||||
-- game_publishers, publishers, game_alt_titles, game_genres und game_mechanics; die Funktion umgeht
|
||||
-- nichts. Gelesen wird über die View catalog_games (ausgeblendete und zusammengeführte Spiele fehlen,
|
||||
-- Bild und Text sind maskiert); title_search kommt per Primärschlüssel aus games.
|
||||
-- Der Partial-Index games_title_search_trgm_idx ist nur über die Bedingungen der View nutzbar.
|
||||
-- Ein set statement_timeout an dieser Funktion wirkt nicht (gilt erst für die nächste Anweisung);
|
||||
-- es gelten die Zeitlimits der Rollen. Suchbegriffe werden nirgends gespeichert oder protokolliert.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
create function public.search_catalog_games(
|
||||
p_query text default null,
|
||||
p_players smallint default null,
|
||||
p_max_minutes smallint default null,
|
||||
p_age smallint default null,
|
||||
p_genre_ids uuid[] default null,
|
||||
p_mechanic_ids uuid[] default null,
|
||||
p_limit integer default 30,
|
||||
p_offset integer default 0
|
||||
)
|
||||
returns table (
|
||||
id uuid,
|
||||
title text,
|
||||
year smallint,
|
||||
min_players smallint,
|
||||
max_players smallint,
|
||||
min_minutes smallint,
|
||||
max_minutes smallint,
|
||||
min_age smallint,
|
||||
image_path text,
|
||||
publisher_names text[],
|
||||
matched_alt_title text,
|
||||
match_rank smallint,
|
||||
total_count bigint
|
||||
)
|
||||
language plpgsql
|
||||
stable
|
||||
set search_path = ''
|
||||
as $$
|
||||
#variable_conflict use_column
|
||||
declare
|
||||
v_limit integer := coalesce(p_limit, 30);
|
||||
v_offset integer := coalesce(p_offset, 0);
|
||||
v_q text;
|
||||
v_qc text;
|
||||
v_words text[] := '{}'::text[];
|
||||
v_patterns text[] := '{}'::text[]; -- '%wort%' je Suchwort, für like all
|
||||
v_has_query boolean := false;
|
||||
v_genres uuid[];
|
||||
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
|
||||
raise exception 'search_query_too_long' using errcode = 'P0001';
|
||||
end if;
|
||||
if (p_players is not null and p_players not between 1 and 99)
|
||||
or (p_max_minutes is not null and p_max_minutes not between 1 and 1440)
|
||||
or (p_age is not null and p_age not between 0 and 99)
|
||||
or cardinality(p_genre_ids) > 20
|
||||
or cardinality(p_mechanic_ids) > 20 then
|
||||
raise exception 'search_filter_invalid' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_limit not between 1 and 50 or v_offset not between 0 and 10000 then
|
||||
raise exception 'search_paging_invalid' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
-- Suchbegriff: null/nur Leerraum = kein Suchbegriff; normalisiert null (z. B. '!!!') = keine Treffer;
|
||||
-- weniger als 2 Zeichen = kein Suchbegriff.
|
||||
if p_query is not null and p_query ~ '\S' then
|
||||
v_q := public.search_normalize(p_query);
|
||||
if v_q is null then
|
||||
return;
|
||||
end if;
|
||||
if cardinality(string_to_array(v_q, ' ')) > 10 then
|
||||
raise exception 'search_query_too_long' using errcode = 'P0001';
|
||||
end if;
|
||||
if char_length(v_q) >= 2 then
|
||||
v_has_query := true;
|
||||
v_qc := replace(v_q, ' ', '');
|
||||
v_words := coalesce(
|
||||
array(select distinct w from unnest(string_to_array(v_q, ' ')) as w where char_length(w) >= 2), '{}');
|
||||
v_patterns := array(select '%' || w || '%' from unnest(v_words) as w);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
v_genres := array(select distinct x from unnest(p_genre_ids) as x where x is not null);
|
||||
v_mechanics := array(select distinct x from unnest(p_mechanic_ids) as x where x is not null);
|
||||
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
|
||||
select s.id, s.title, s.year, s.min_players, s.max_players, s.min_minutes, s.max_minutes, s.min_age,
|
||||
s.image_path,
|
||||
coalesce((select array_agg(p.name order by p.name_search, p.name)
|
||||
from public.game_publishers gp
|
||||
join public.publishers p on p.id = gp.publisher_id
|
||||
where gp.game_id = s.id), '{}'::text[]),
|
||||
null::text, null::smallint, s.total
|
||||
from (
|
||||
select c.id, c.title, c.year, c.min_players, c.max_players, c.min_minutes, c.max_minutes, c.min_age,
|
||||
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 (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
|
||||
order by s.title_search collate "C", s.id;
|
||||
return;
|
||||
end if;
|
||||
|
||||
return query
|
||||
with base as (
|
||||
select c.id, c.title, c.year, c.min_players, c.max_players, c.min_minutes, c.max_minutes, c.min_age,
|
||||
c.image_path, g.title_search
|
||||
from public.catalog_games c
|
||||
join public.games g on g.id = c.id
|
||||
where (not v_filtered or c.id in (select unnest(v_ids)))
|
||||
),
|
||||
-- Kandidaten-Titel: Haupttitel (alt_title null) und alle alternativen Titel
|
||||
cand as (
|
||||
select b.id, b.title_search as ts, null::text as alt_title from base b
|
||||
union all
|
||||
select b.id, a.title_search, a.title
|
||||
from base b join public.game_alt_titles a on a.game_id = b.id
|
||||
),
|
||||
-- Relevanzstufe je Kandidaten-Titel (null = kein Titeltreffer). Nur günstige Vergleiche; die teure
|
||||
-- Trigramm-Ähnlichkeit wird erst für die Treffer berechnet (sims, alt_pick).
|
||||
ranked as (
|
||||
select t.id, t.ts, t.alt_title,
|
||||
case
|
||||
-- Stufe 0: gleich dem Suchbegriff (auch ohne Leerzeichen gleich; das schließt ts = v_q ein)
|
||||
when replace(t.ts, ' ', '') = v_qc then 0
|
||||
when t.ts like v_q || '%' then 1
|
||||
when t.ts like '% ' || v_q || '%' then 2
|
||||
when replace(t.ts, ' ', '') like '%' || v_qc || '%'
|
||||
or (cardinality(v_patterns) > 0 and t.ts like all (v_patterns))
|
||||
then 3
|
||||
end as rk
|
||||
from cand t
|
||||
),
|
||||
agg as (
|
||||
select r.id,
|
||||
min(r.rk) as best_rk,
|
||||
min(r.rk) filter (where r.alt_title is null) as main_rk,
|
||||
min(r.rk) filter (where r.alt_title is not null) as alt_rk
|
||||
from ranked r
|
||||
group by r.id
|
||||
),
|
||||
-- der alternative Titel mit der besten Stufe (bei Gleichstand: größte Ähnlichkeit, dann Schreibweise)
|
||||
alt_pick as (
|
||||
select distinct on (r.id) r.id, r.alt_title
|
||||
from ranked r
|
||||
where r.alt_title is not null and r.rk is not null
|
||||
order by r.id, r.rk, extensions.similarity(r.ts, v_q) desc, r.ts, r.alt_title
|
||||
),
|
||||
-- Rang 4: welches Suchwort kommt in welchem Spiel in einem Titel oder einem Verlagsnamen vor?
|
||||
-- (als Mengenoperation statt korrelierter Unterabfrage je Spiel: sonst wächst die Zeit quadratisch)
|
||||
word_hits as (
|
||||
select r.id, w.w from ranked r cross join unnest(v_words) as w(w) where r.ts like '%' || w.w || '%'
|
||||
union
|
||||
select gp.game_id, w.w
|
||||
from public.publishers p
|
||||
join public.game_publishers gp on gp.publisher_id = p.id
|
||||
cross join unnest(v_words) as w(w)
|
||||
where p.name_search like '%' || w.w || '%'
|
||||
),
|
||||
word_cover as (
|
||||
select wh.id, count(*) as n from word_hits wh group by wh.id
|
||||
),
|
||||
hit as (
|
||||
select b.*, a.best_rk, a.main_rk, a.alt_rk, ap.alt_title
|
||||
from base b
|
||||
join agg a on a.id = b.id
|
||||
left join alt_pick ap on ap.id = b.id
|
||||
left join word_cover wc on wc.id = b.id
|
||||
where a.best_rk is not null
|
||||
-- Rang 4: jedes Wort kommt in einem Titel oder einem Verlagsnamen des Spiels vor
|
||||
or (cardinality(v_words) > 0 and wc.n = cardinality(v_words))
|
||||
),
|
||||
-- Ähnlichkeit (zweites Sortierkriterium): die höchste über Haupttitel und alle alternativen Titel
|
||||
sims as (
|
||||
select r.id, max(extensions.similarity(r.ts, v_q)) as sim
|
||||
from ranked r
|
||||
join hit h on h.id = r.id
|
||||
group by r.id
|
||||
),
|
||||
paged as (
|
||||
select h.*, sm.sim, coalesce(h.best_rk, 4) as rk, count(*) over () as total
|
||||
from hit h
|
||||
join sims sm on sm.id = h.id
|
||||
order by coalesce(h.best_rk, 4), sm.sim desc nulls last, h.title_search collate "C", h.id
|
||||
limit v_limit offset v_offset
|
||||
)
|
||||
select s.id, s.title, s.year, s.min_players, s.max_players, s.min_minutes, s.max_minutes, s.min_age,
|
||||
s.image_path,
|
||||
coalesce((select array_agg(p.name order by p.name_search, p.name)
|
||||
from public.game_publishers gp
|
||||
join public.publishers p on p.id = gp.publisher_id
|
||||
where gp.game_id = s.id), '{}'::text[]),
|
||||
-- nur, wenn ein alternativer Titel besser getroffen hat als der Haupttitel
|
||||
case when s.alt_rk is not null and (s.main_rk is null or s.alt_rk < s.main_rk) then s.alt_title end,
|
||||
s.rk::smallint, s.total
|
||||
from paged s
|
||||
order by s.rk, s.sim desc nulls last, s.title_search collate "C", s.id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Rechte-Regel: erst allen entziehen, dann nur anon und authenticated (erste Abfrage-RPC für API-Rollen).
|
||||
revoke execute on function public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer)
|
||||
from public, anon, authenticated, service_role;
|
||||
grant execute on function public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer)
|
||||
to anon, authenticated;
|
||||
225
supabase/perf/catalog_search_perf.sql
Normal file
225
supabase/perf/catalog_search_perf.sql
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
-- Messung der Katalogsuche (KAT-01, Spec #3 Abschnitt 4.2 "Leistung", Aufgabe B5).
|
||||
-- KEIN pgTAP-Test und liegt bewusst nicht unter supabase/tests/ (supabase test db würde es sonst mitlaufen lassen).
|
||||
--
|
||||
-- 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: 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 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 \
|
||||
-- < supabase/perf/catalog_search_perf.sql
|
||||
-- Das statement_timeout der Rolle anon (lokal 3 s) wirkt bei set local role nicht von allein und gälte hier für
|
||||
-- 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
|
||||
\endif
|
||||
\if :{?n_iter}
|
||||
\else
|
||||
\set n_iter 100
|
||||
\endif
|
||||
|
||||
begin;
|
||||
do $guard$
|
||||
begin
|
||||
if (select count(*) from auth.users) > 20 or (select count(*) from public.games where visibility = 'private') > 20 then
|
||||
raise exception 'Abbruch: Die Datenbank sieht nach echten Daten aus (mehr als 20 Konten oder private Spiele). Dieses Skript läuft nur lokal.';
|
||||
end if;
|
||||
end
|
||||
$guard$;
|
||||
|
||||
select setseed(0.42);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Synthetische Daten (als postgres, vor dem Rollenwechsel)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
create schema perf_tmp;
|
||||
grant usage on schema perf_tmp to anon;
|
||||
|
||||
-- ~3000 Pseudo-Wörter mit ungleicher Häufigkeit (häufige Wörter wie in echten Titeln)
|
||||
create table perf_tmp.words as
|
||||
select i, translate(substr(md5('w' || i), 1, 4 + (i % 6)), '0123456789', 'ghijklmnop') as w
|
||||
from generate_series(1, 3000) i;
|
||||
alter table perf_tmp.words add primary key (i);
|
||||
create function perf_tmp.pick() returns text language sql volatile as
|
||||
$$ select w from perf_tmp.words where i = (select 1 + floor(power(random(), 2.2) * 3000)::int) $$;
|
||||
|
||||
insert into public.genres (name_de, name_en)
|
||||
select 'Perf Genre ' || i, 'Perf genre ' || i from generate_series(1, 15) i;
|
||||
insert into public.mechanics (name_de, name_en)
|
||||
select 'Perf Mechanik ' || i, 'Perf mechanic ' || i from generate_series(1, 25) i;
|
||||
|
||||
insert into public.publishers (name)
|
||||
select initcap(perf_tmp.pick()) || ' Spiele ' || i from generate_series(1, 2000) i;
|
||||
|
||||
insert into public.games (title, visibility, year, min_players, max_players, min_minutes, max_minutes, min_age, description, description_locale)
|
||||
select initcap(concat_ws(' ', perf_tmp.pick(), case when k >= 2 then perf_tmp.pick() end, case when k >= 3 then perf_tmp.pick() end,
|
||||
case when k >= 4 then perf_tmp.pick() end)),
|
||||
'catalog', 1990 + (g % 35), mn, mn + (g % 5), 10 + (g % 12) * 10, 30 + (g % 12) * 10, 6 + (g % 4) * 3,
|
||||
'Synthetischer Kurztext ' || g, 'de'
|
||||
from (select g, 1 + floor(random() * 3.99)::int as k, 1 + (g % 4) as mn from generate_series(1, :n_games) g) s;
|
||||
|
||||
-- 2 alternative Titel je Spiel (de/en), Duplikate je Sprache entfallen
|
||||
insert into public.game_alt_titles (game_id, locale, title)
|
||||
select g.id, l.locale, initcap(perf_tmp.pick() || ' ' || perf_tmp.pick())
|
||||
from public.games g, (values ('de'), ('en')) as l(locale)
|
||||
where g.visibility = 'catalog'
|
||||
on conflict do nothing;
|
||||
|
||||
insert into public.game_publishers (game_id, publisher_id)
|
||||
select g.id, (select id from public.publishers order by random() limit 1) from public.games g where g.visibility = 'catalog' and g.title <> ''
|
||||
on conflict do nothing;
|
||||
insert into public.game_publishers (game_id, publisher_id)
|
||||
select g.id, (select id from public.publishers order by random() limit 1) from public.games g where random() < 0.3
|
||||
on conflict do nothing;
|
||||
|
||||
insert into public.game_genres (game_id, genre_id)
|
||||
select g.id, t.id from public.games g cross join lateral
|
||||
(select x.id from public.genres x where x.name_de like 'Perf Genre %' order by md5(g.id::text || x.id::text) limit 2) t on conflict do nothing;
|
||||
insert into public.game_mechanics (game_id, mechanic_id)
|
||||
select g.id, t.id from public.games g cross join lateral
|
||||
(select x.id from public.mechanics x where x.name_de like 'Perf Mechanik %' order by md5(g.id::text || x.id::text) limit 3) t on conflict do nothing;
|
||||
|
||||
analyze public.games;
|
||||
analyze public.game_alt_titles;
|
||||
analyze public.publishers;
|
||||
analyze public.game_publishers;
|
||||
analyze public.game_genres;
|
||||
analyze public.game_mechanics;
|
||||
|
||||
\echo === Datenumfang ===
|
||||
select (select count(*) from public.catalog_games) as katalogspiele,
|
||||
(select count(*) from public.game_alt_titles) as alt_titel,
|
||||
(select count(*) from public.publishers) as verlage,
|
||||
(select count(*) from public.game_publishers) as verlag_zuordnungen,
|
||||
pg_size_pretty(pg_relation_size('public.games')) as games_tabelle,
|
||||
pg_size_pretty(pg_relation_size('public.games_title_search_trgm_idx')) as games_trgm_index,
|
||||
pg_size_pretty(pg_relation_size('public.game_alt_titles_title_search_trgm_idx')) as alt_trgm_index;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Abfragen für die Fälle (Begriffe aus den vorhandenen Daten, damit sie Treffer haben)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
create table perf_tmp.q (
|
||||
id serial, fall text, term text, players smallint, minutes smallint, age smallint, genre uuid, mech uuid, off int default 0);
|
||||
grant select on perf_tmp.q to anon;
|
||||
create table perf_tmp.r (fall text, ms float8, n bigint);
|
||||
grant insert, select on perf_tmp.r to anon;
|
||||
|
||||
with sample as (select g.id, g.title, lower(g.title) as lt, string_to_array(lower(g.title), ' ') as ws
|
||||
from public.games g order by random() limit :n_iter),
|
||||
genre as (select id from public.genres where name_de like 'Perf Genre %' order by random() limit 1),
|
||||
mech as (select id from public.mechanics where name_de like 'Perf Mechanik %' order by random() limit 1)
|
||||
insert into perf_tmp.q (fall, term, players, minutes, age, genre, mech, off)
|
||||
select '01 Titelteil (4 Zeichen)', substr(ws[1], 1, 4), null::smallint, null::smallint, null::smallint, null::uuid, null::uuid, 0 from sample
|
||||
union all select '02 zwei Zeichen', substr(ws[1], 1, 2), null, null, null, null, null, 0 from sample
|
||||
union all select '03 ganzes Wort', ws[1], null, null, null, null, null, 0 from sample
|
||||
union all select '04 zwei Wörter', case when cardinality(ws) >= 2 then ws[1] || ' ' || ws[2] else ws[1] end, null, null, null, null, null, 0 from sample
|
||||
union all select '05 ohne Leerzeichen', case when cardinality(ws) >= 2 then ws[1] || ws[2] else ws[1] end, null, null, null, null, null, 0 from sample
|
||||
union all select '06 Verlag (ein Wort)', lower(split_part(p.name, ' ', 1)), null, null, null, null, null, 0
|
||||
from (select name from public.publishers order by random() limit :n_iter) p
|
||||
union all select '06b Verlag + häufiges Wort', lower(split_part(p.name, ' ', 1)) || ' spiele', null, null, null, null, null, 0
|
||||
from (select name from public.publishers order by random() limit :n_iter) p
|
||||
union all select '07 alternativer Titel', split_part(lower(a.title), ' ', 1), null, null, null, null, null, 0
|
||||
from (select title from public.game_alt_titles order by random() limit :n_iter) a
|
||||
union all select '08 Begriff + Filter', ws[1], 3, null, null, (select id from genre), null, 0 from sample
|
||||
union all select '09 nur Filter (Spielerzahl, Genre)', null, 3, null, null, (select id from genre), null, 0 from sample
|
||||
union all select '10 nur alle Filter', null, 3, 90, 12, (select id from genre), (select id from mech), 0 from sample
|
||||
union all select '11 ohne alles (erste Seite)', null, null, null, null, null, null, 0 from sample
|
||||
union all select '12 ohne alles (Versatz 5000)', null, null, null, null, null, null, 5000 from sample
|
||||
union all select '13 ohne Treffer', 'zzqqxx' || substr(md5(id::text), 1, 3), null, null, null, null, null, 0 from sample;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Phase A: Pläne (auto_explain, als anon mit RLS). Die Pläne kommen als NOTICE des inneren Statements.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
\echo === Phase A: explain (analyze, buffers) als anon mit RLS ===
|
||||
set local auto_explain.log_min_duration = 0;
|
||||
set local auto_explain.log_analyze = on;
|
||||
set local auto_explain.log_buffers = on;
|
||||
set local auto_explain.log_timing = off; -- die Zeitmessung selbst verfälscht die Zeiten stark; Zeiten stehen in Phase B
|
||||
set local auto_explain.log_nested_statements = on;
|
||||
set local auto_explain.log_level = 'notice';
|
||||
set local role anon;
|
||||
set local request.jwt.claims = '{"role":"anon"}';
|
||||
|
||||
do $a$
|
||||
declare
|
||||
c record;
|
||||
begin
|
||||
for c in select distinct on (fall) fall, term, players, minutes, age, genre, mech, off from perf_tmp.q order by fall, id loop
|
||||
raise notice '##### %: term=%, players=%, off=%', c.fall, c.term, c.players, c.off;
|
||||
perform * from public.search_catalog_games(c.term, c.players, c.minutes, c.age,
|
||||
case when c.genre is null then null else array[c.genre] end, case when c.mech is null then null else array[c.mech] end, 30, c.off);
|
||||
end loop;
|
||||
end
|
||||
$a$;
|
||||
reset role;
|
||||
set local auto_explain.log_min_duration = -1;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Phase B: Zeiten (clock_timestamp um jeden Aufruf), als anon mit RLS
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
\echo === Phase B: Zeiten als anon mit RLS ===
|
||||
set local role anon;
|
||||
set local request.jwt.claims = '{"role":"anon"}';
|
||||
do $b$
|
||||
declare
|
||||
c record;
|
||||
t0 timestamptz;
|
||||
n bigint;
|
||||
begin
|
||||
for c in select * from perf_tmp.q order by id loop
|
||||
t0 := clock_timestamp();
|
||||
select coalesce(max(total_count), 0) into n from public.search_catalog_games(c.term, c.players, c.minutes, c.age,
|
||||
case when c.genre is null then null else array[c.genre] end, case when c.mech is null then null else array[c.mech] end, 30, c.off);
|
||||
insert into perf_tmp.r values (c.fall, extract(epoch from clock_timestamp() - t0) * 1000, n);
|
||||
end loop;
|
||||
end
|
||||
$b$;
|
||||
reset role;
|
||||
|
||||
select fall,
|
||||
count(*) as aufrufe,
|
||||
round(percentile_cont(0.5) within group (order by ms)::numeric, 1) as p50_ms,
|
||||
round(percentile_cont(0.95) within group (order by ms)::numeric, 1) as p95_ms,
|
||||
round(percentile_cont(0.99) within group (order by ms)::numeric, 1) as p99_ms,
|
||||
round(max(ms)::numeric, 1) as max_ms,
|
||||
count(*) filter (where ms > 3000) as ueber_3s,
|
||||
round(avg(n)::numeric, 0) as treffer_mittel
|
||||
from perf_tmp.r group by fall order by fall;
|
||||
|
||||
select 'alle Fälle zusammen' as fall, count(*) as aufrufe,
|
||||
round(percentile_cont(0.95) within group (order by ms)::numeric, 1) as p95_ms,
|
||||
round(max(ms)::numeric, 1) as max_ms
|
||||
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;
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
begin;
|
||||
select plan(5);
|
||||
|
||||
select is((select count(*) from pg_tables where schemaname = 'public'), 21::bigint,
|
||||
'R1 hat genau 21 Tabellen (17 + catalog_sources, publishers, game_publishers, game_reports)');
|
||||
select is((select count(*) from pg_tables where schemaname = 'public'), 22::bigint,
|
||||
'R1 hat genau 22 Tabellen (17 + catalog_sources, publishers, game_publishers, game_reports, game_alt_titles)');
|
||||
|
||||
select is_empty($$
|
||||
select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
||||
|
|
|
|||
|
|
@ -149,6 +149,10 @@ select is((select manual_fields from public.games where id = 'f6000000-0000-0000
|
|||
create role catalog_importer nologin;
|
||||
grant catalog_importer to postgres with set true;
|
||||
grant usage on schema public to catalog_importer;
|
||||
-- Seit KAT-01 berechnet jeder Schreibende title_search selbst: auch der Import braucht EXECUTE auf
|
||||
-- search_normalize und USAGE auf das Schema extensions (unaccent). Hinweis für SAM-08.
|
||||
grant execute on function public.search_normalize(text) to catalog_importer;
|
||||
grant usage on schema extensions to catalog_importer;
|
||||
grant select, insert, update on public.games to catalog_importer;
|
||||
grant select, insert, update, delete on public.game_genres to catalog_importer;
|
||||
create policy t_importer_games on public.games for all to catalog_importer using (true) with check (true);
|
||||
|
|
@ -584,7 +588,7 @@ select is_empty($$select * from snap_rows s where not exists (select 1 from (
|
|||
-- Prüfabfrage checks.sql (Fixture-Variante derselben Abfrage)
|
||||
\ir ../support/checks_fixture.inc
|
||||
select results_eq($$select rule, ist::int, status from checks_result order by rule$$,
|
||||
$$values ('Info'::text, 1, 'Information'::text), ('SB0', 3, 'Information'), ('SB1', 2, 'Verstoß'),
|
||||
$$values ('Info'::text, 1, 'Information'::text), ('Info-Alt', 1, 'Information'), ('SB0', 3, 'Information'), ('SB1', 2, 'Verstoß'),
|
||||
('SB2', 0, 'ok'), ('SB4', 0, 'ok'), ('SB6', 0, 'ok')$$,
|
||||
'KAT-05 Kriterium 10: checks.sql meldet für die Testspiele keinen Verstoß bei SB2, SB4, SB6 (SB1 liegt lokal erwartbar unter 180)');
|
||||
update public.games set min_minutes = null where title = 'Testspiel A';
|
||||
|
|
|
|||
|
|
@ -633,6 +633,7 @@ select set_eq($$select fk from fk_found$$,
|
|||
('game_variants.game_id'), ('games.merged_into_id'), ('game_reports.target_game_id'),
|
||||
-- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag
|
||||
('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id'),
|
||||
('game_alt_titles.game_id'),
|
||||
-- bewusst ausgelassen: zeigt auf ein privates Spiel, das Zusammenführen zweier Katalogspiele betrifft ihn nicht
|
||||
('game_reports.provisional_game_id')$$,
|
||||
'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen');
|
||||
|
|
|
|||
|
|
@ -731,6 +731,7 @@ select set_eq($$select fk from fk_found$$,
|
|||
('game_variants.game_id'), ('games.merged_into_id'), ('game_reports.target_game_id'),
|
||||
-- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag
|
||||
('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id'),
|
||||
('game_alt_titles.game_id'),
|
||||
-- bewusst ausgelassen: zeigt auf ein privates Spiel, das Zusammenführen zweier Katalogspiele betrifft ihn nicht
|
||||
('game_reports.provisional_game_id')$$,
|
||||
'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen');
|
||||
|
|
|
|||
850
supabase/tests/database/09_catalog_search.test.sql
Normal file
850
supabase/tests/database/09_catalog_search.test.sql
Normal file
|
|
@ -0,0 +1,850 @@
|
|||
-- KAT-01 (PR 3a): Katalogsuche. search_normalize, generierte Spalten, game_alt_titles, RPC
|
||||
-- search_catalog_games, Rechte, Idempotenz der alternativen Titel im Startbestand-Werkzeug und die
|
||||
-- SQL-Bausteine aus docs/katalog-pflege.md (Abschnitte 3.1, 5 und 8; Kommentare mit Abschnittsverweis).
|
||||
-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix f9) und dem
|
||||
-- Kunstwort "qxz" in allen Titeln und Verlagen, damit Suchen nie Startbestandsspiele treffen.
|
||||
-- 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(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"}'),
|
||||
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Erweiterungen und Rechte (Sicherheit Kriterium 2, Spec 3.2)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select is((select string_agg(extname || ':' || extnamespace::regnamespace::text, ',' order by extname)
|
||||
from pg_extension where extname in ('pg_trgm', 'unaccent')),
|
||||
'pg_trgm:extensions,unaccent:extensions', 'KAT-01: pg_trgm und unaccent liegen im Schema extensions');
|
||||
select ok(has_function_privilege('anon', 'extensions.unaccent(regdictionary, text)', 'execute')
|
||||
and has_function_privilege('authenticated', 'extensions.unaccent(regdictionary, text)', 'execute')
|
||||
and has_function_privilege('anon', 'extensions.similarity(text, text)', 'execute')
|
||||
and has_function_privilege('authenticated', 'extensions.similarity(text, text)', 'execute')
|
||||
and has_schema_privilege('anon', 'extensions', 'usage') and has_schema_privilege('authenticated', 'extensions', 'usage'),
|
||||
'KAT-01: anon und authenticated dürfen unaccent und similarity im Schema extensions ausführen (B5)');
|
||||
|
||||
select ok(has_function_privilege('anon', 'public.search_normalize(text)', 'execute')
|
||||
and has_function_privilege('authenticated', 'public.search_normalize(text)', 'execute')
|
||||
and has_function_privilege('service_role', 'public.search_normalize(text)', 'execute'),
|
||||
'KAT-01: search_normalize ist für anon, authenticated und service_role ausführbar');
|
||||
select is((select p.proacl is not null and not exists (select 1 from aclexplode(p.proacl) a where a.grantee = 0)
|
||||
from pg_proc p where p.oid = 'public.search_normalize(text)'::regprocedure), true,
|
||||
'KAT-01: search_normalize ist für public (alle) nicht ausführbar (Rechteliste ausdrücklich gesetzt)');
|
||||
select ok(has_function_privilege('anon', 'public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer)', 'execute')
|
||||
and has_function_privilege('authenticated', 'public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer)', 'execute'),
|
||||
'KAT-01 Sicherheit Kriterium 2: anon und authenticated dürfen search_catalog_games ausführen');
|
||||
select ok(not has_function_privilege('service_role', 'public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer)', 'execute'),
|
||||
'KAT-01: service_role darf search_catalog_games nicht ausführen');
|
||||
select is((select p.proacl is not null and not exists (select 1 from aclexplode(p.proacl) a where a.grantee = 0)
|
||||
from pg_proc p where p.oid = 'public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer)'::regprocedure), true,
|
||||
'KAT-01: search_catalog_games ist für public (alle) nicht ausführbar (Rechteliste ausdrücklich gesetzt)');
|
||||
select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'search_normalize'
|
||||
and p.provolatile = 'i' and p.proisstrict and p.proparallel = 's' and not p.prosecdef
|
||||
and p.proconfig = array['search_path=""']), 1::bigint,
|
||||
'KAT-01: search_normalize ist immutable, strict, parallel safe, security invoker mit leerem search_path');
|
||||
select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'search_catalog_games'
|
||||
and p.provolatile = 's' and not p.prosecdef and p.proconfig = array['search_path=""']), 1::bigint,
|
||||
'KAT-01: search_catalog_games ist stable, security invoker mit leerem search_path');
|
||||
select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'search_catalog_games'), 1::bigint,
|
||||
'KAT-01: es gibt genau eine Überladung von search_catalog_games');
|
||||
select ok(exists (select 1 from pg_roles where rolname = 'anon' and exists (select 1 from unnest(rolconfig) c where c like 'statement_timeout=%'))
|
||||
and exists (select 1 from pg_roles where rolname = 'authenticated' and exists (select 1 from unnest(rolconfig) c where c like 'statement_timeout=%')),
|
||||
'KAT-01 Sicherheit (R4): anon und authenticated haben ein statement_timeout (schützt die Suche ohne Anmeldung)');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- search_normalize: Pflichtfälle (Spec 3.2 Nr. 2) und weitere Fälle
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select is(public.search_normalize('Die Siedler von Catan'), 'die siedler von catan', 'KAT-01 Kriterium 1: Pflichtfall Die Siedler von Catan');
|
||||
select is(public.search_normalize('CATAN®'), 'catan', 'KAT-01 Kriterium 1: Pflichtfall CATAN® (Schutzzeichen entfernt, kein "catan r")');
|
||||
select is(public.search_normalize('Spiel™ ½'), 'spiel 1 2', 'KAT-01 Kriterium 1: Pflichtfall Spiel™ ½ (Bruch wie von unaccent umgeschrieben)');
|
||||
select is(public.search_normalize('Æon’s End'), 'aeon s end', 'KAT-01 Kriterium 1: Pflichtfall Æon’s End (Apostroph wird Leerzeichen)');
|
||||
select is(public.search_normalize('7 Wonders: Duel'), '7 wonders duel', 'KAT-01 Kriterium 1: Pflichtfall 7 Wonders: Duel');
|
||||
select is(public.search_normalize('Café Größe'), 'cafe grosse', 'KAT-01 Kriterium 1: Pflichtfall Café Größe (é, ö, ß)');
|
||||
select is(public.search_normalize('Ticket to Ride – Europe'), 'ticket to ride europe', 'KAT-01 Kriterium 1: Pflichtfall Gedankenstrich');
|
||||
select is(public.search_normalize('!!!'), null, 'KAT-01 Kriterium 1: nur Sonderzeichen ergibt null');
|
||||
select is(public.search_normalize(''), null, 'KAT-01 Kriterium 1: leerer Text ergibt null');
|
||||
select is(public.search_normalize(null), null, 'KAT-01: null ergibt null (strict)');
|
||||
select is(public.search_normalize(' ©Ärger℠ ℗ Ü '), 'arger u', 'KAT-01 Kriterium 1: alle Schutzzeichen, Umlaute und Ränder');
|
||||
select is(public.search_normalize('Die Siedler, von Catan'), 'die siedler von catan', 'KAT-01: mehrere Trenner werden zu einem Leerzeichen');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Generierte Spalten, Indizes, Tabelle game_alt_titles (Kriterium 2)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select is((select string_agg(attrelid::regclass::text || '.' || attname, ',' order by attrelid::regclass::text)
|
||||
from pg_attribute where attgenerated = 's' and attname in ('title_search', 'name_search')
|
||||
and attrelid in ('public.games'::regclass, 'public.publishers'::regclass, 'public.game_alt_titles'::regclass)),
|
||||
'game_alt_titles.title_search,games.title_search,publishers.name_search',
|
||||
'KAT-01: title_search und name_search sind gespeicherte generierte Spalten');
|
||||
select is((select count(*) from pg_indexes where schemaname = 'public'
|
||||
and indexdef like '%USING gin (title_search %gin_trgm_ops)%'
|
||||
and indexname in ('games_title_search_trgm_idx', 'game_alt_titles_title_search_trgm_idx')), 2::bigint,
|
||||
'KAT-01: Trigramm-Indizes auf games.title_search und game_alt_titles.title_search');
|
||||
select is((select count(*) from pg_indexes where schemaname = 'public' and indexname = 'games_title_search_trgm_idx'
|
||||
and indexdef like '%WHERE%visibility%catalog%hidden_at IS NULL%merged_into_id IS NULL%'), 1::bigint,
|
||||
'KAT-01: der Index auf games.title_search ist ein Partial-Index mit den Bedingungen der View catalog_games');
|
||||
select is((select count(*) from pg_indexes where schemaname = 'public' and indexname = 'publishers_name_search_trgm_idx'
|
||||
and indexdef like '%USING gin (name_search %gin_trgm_ops)%'), 1::bigint,
|
||||
'KAT-01: Trigramm-Index auf publishers.name_search');
|
||||
select is((select confdeltype::text from pg_constraint where conrelid = 'public.game_alt_titles'::regclass and contype = 'f'
|
||||
and confrelid = 'public.games'::regclass), 'c', 'KAT-01 Kriterium 2: game_alt_titles.game_id löscht mit dem Spiel (on delete cascade)');
|
||||
select ok(exists (select 1 from pg_trigger where tgrelid = 'public.game_alt_titles'::regclass and tgname = 'game_links_mark_manual'),
|
||||
'KAT-01: game_alt_titles hat den Trigger game_links_mark_manual');
|
||||
|
||||
-- Rechte der Tabelle: nur Lesen für API-Rollen
|
||||
select ok(has_table_privilege('anon', 'public.game_alt_titles', 'select') and has_table_privilege('authenticated', 'public.game_alt_titles', 'select'),
|
||||
'KAT-01 Kriterium 2: anon und authenticated dürfen game_alt_titles lesen');
|
||||
select is_empty($$select r, p from unnest(array['anon', 'authenticated']) r,
|
||||
unnest(array['insert', 'update', 'delete', 'truncate', 'references', 'trigger']) p
|
||||
where has_table_privilege(r, 'public.game_alt_titles', p)$$,
|
||||
'KAT-01 Kriterium 2: anon und authenticated haben kein Schreibrecht und keine weiteren Rechte auf game_alt_titles');
|
||||
select is_empty($$select r, c from unnest(array['anon', 'authenticated']) r, unnest(array['game_id', 'title', 'locale', 'id']) c
|
||||
where has_column_privilege(r, 'public.game_alt_titles', c, 'insert') or has_column_privilege(r, 'public.game_alt_titles', c, 'update')$$,
|
||||
'KAT-01 Kriterium 2: auch auf Spaltenebene kein Schreibrecht auf game_alt_titles');
|
||||
select results_eq($$select polcmd::text, array(select rolname from pg_roles where oid = any(polroles) order by 1)
|
||||
from pg_policy where polrelid = 'public.game_alt_titles'::regclass$$,
|
||||
$$values ('r'::text, array['anon', 'authenticated']::name[])$$,
|
||||
'KAT-01 Kriterium 2: game_alt_titles hat genau eine Policy, nur select für anon und authenticated');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Fixtures (alle Titel und Verlage mit "qxz")
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
insert into public.genres (id, name_de, name_en) values
|
||||
('f9000000-0000-0000-0000-0000000000e1', 'Qxz Genre A', 'Qxz genre A'),
|
||||
('f9000000-0000-0000-0000-0000000000e2', 'Qxz Genre B', 'Qxz genre B'),
|
||||
('f9000000-0000-0000-0000-0000000000e3', 'Qxz Genre Q', 'Qxz genre Q'),
|
||||
('f9000000-0000-0000-0000-0000000000e4', 'Qxz Genre R', 'Qxz genre R');
|
||||
insert into public.mechanics (id, name_de, name_en) values
|
||||
('f9000000-0000-0000-0000-0000000000d1', 'Qxz Mechanik A', 'Qxz mechanic A'),
|
||||
('f9000000-0000-0000-0000-0000000000d2', 'Qxz Mechanik B', 'Qxz mechanic B');
|
||||
insert into public.publishers (id, name) values
|
||||
('f9000000-0000-0000-0000-0000000000b2', 'Qxz Hersteller Eins'),
|
||||
('f9000000-0000-0000-0000-0000000000b1', 'Qxz Hersteller Zwei'),
|
||||
('f9000000-0000-0000-0000-0000000000b3', 'Qxz Zorb Verlag');
|
||||
|
||||
insert into public.games (id, title, visibility) values
|
||||
('f9000000-0000-0000-0000-000000000001', 'QXZ Zorb', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000002', 'Qxz Zorb Deluxe', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000003', 'Das Qxz Zorb', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000004', 'Zorb of Qxz', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000005', 'Qxz Great Zorb', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000006', 'Qxz Spiel', 'catalog'),
|
||||
-- Gleichstand: erst die größere ID eingefügt, damit die Heap-Reihenfolge nicht zufällig die ID-Reihenfolge ist
|
||||
('f9000000-0000-0000-0000-000000000008', 'Qxz Twin', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000007', 'QXZ TWIN', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000010', 'Qxz Hauptspiel Alpha', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000011', 'Alpha Qxz Bird', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000012', 'Qxz Einzigartig', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000013', 'Qxz Käse Größe', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000014', 'Qxz Æon’s End', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000015', 'Qxz 7 Wonders', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000016', 'QXZ CATAN®', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000040', 'Qxz Doppelspiel', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000041', 'Qxz Einzelspiel', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000060', 'Qxz Cabin', 'catalog'),
|
||||
-- Sortierung ist binär (collate "C"): "qxz b" kommt vor "qxzab". Hinweis: Mit ICU-Sortierung (wie lokal) gilt das auch
|
||||
-- ohne collate "C"; der Test ist dort nicht mutationsempfindlich und schützt vor libc-Sortierungen, die Leerzeichen ignorieren.
|
||||
('f9000000-0000-0000-0000-000000000081', 'Qxzab', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000095', 'Qxz Zwilling', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000093', 'Qxz Zwilling', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000091', 'Qxz Zwilling', 'catalog'),
|
||||
('f9000000-0000-0000-0000-000000000094', 'Qxz Zwilling', 'catalog'),
|
||||
('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
|
||||
insert into public.games (id, title, visibility, min_players, max_players, min_minutes, max_minutes, min_age) values
|
||||
('f9000000-0000-0000-0000-000000000020', 'Qxz Filter 20', 'catalog', 2, 4, 30, 60, 8),
|
||||
('f9000000-0000-0000-0000-000000000021', 'Qxz Filter 21', 'catalog', 3, 6, 60, 90, 12),
|
||||
('f9000000-0000-0000-0000-000000000022', 'Qxz Filter 22', 'catalog', null, null, null, null, null),
|
||||
('f9000000-0000-0000-0000-000000000023', 'Qxz Filter 23', 'catalog', 2, 2, null, 45, 6),
|
||||
('f9000000-0000-0000-0000-000000000024', 'Qxz Filter 24', 'catalog', 1, 8, 20, null, 0),
|
||||
('f9000000-0000-0000-0000-000000000025', 'Qxz Filter 25', 'catalog', 2, 5, 30, 30, 10);
|
||||
insert into public.game_genres (game_id, genre_id) values
|
||||
('f9000000-0000-0000-0000-000000000020', 'f9000000-0000-0000-0000-0000000000e1'),
|
||||
('f9000000-0000-0000-0000-000000000020', 'f9000000-0000-0000-0000-0000000000e2'),
|
||||
('f9000000-0000-0000-0000-000000000021', 'f9000000-0000-0000-0000-0000000000e1'),
|
||||
('f9000000-0000-0000-0000-000000000022', 'f9000000-0000-0000-0000-0000000000e1'),
|
||||
('f9000000-0000-0000-0000-000000000025', 'f9000000-0000-0000-0000-0000000000e2'),
|
||||
('f9000000-0000-0000-0000-000000000005', 'f9000000-0000-0000-0000-0000000000e3'),
|
||||
('f9000000-0000-0000-0000-000000000060', 'f9000000-0000-0000-0000-0000000000e3'),
|
||||
('f9000000-0000-0000-0000-000000000080', 'f9000000-0000-0000-0000-0000000000e4'),
|
||||
('f9000000-0000-0000-0000-000000000081', 'f9000000-0000-0000-0000-0000000000e4');
|
||||
insert into public.game_mechanics (game_id, mechanic_id) values
|
||||
('f9000000-0000-0000-0000-000000000020', 'f9000000-0000-0000-0000-0000000000d1'),
|
||||
('f9000000-0000-0000-0000-000000000021', 'f9000000-0000-0000-0000-0000000000d1'),
|
||||
('f9000000-0000-0000-0000-000000000021', 'f9000000-0000-0000-0000-0000000000d2'),
|
||||
('f9000000-0000-0000-0000-000000000025', 'f9000000-0000-0000-0000-0000000000d2');
|
||||
|
||||
-- Verlage: 01 und 30 bei Eins; 40 bei Eins und Zwei; 41 bei Zwei; 06 bei "Qxz Zorb Verlag"
|
||||
insert into public.game_publishers (game_id, publisher_id) values
|
||||
('f9000000-0000-0000-0000-000000000001', 'f9000000-0000-0000-0000-0000000000b2'),
|
||||
('f9000000-0000-0000-0000-000000000040', 'f9000000-0000-0000-0000-0000000000b1'),
|
||||
('f9000000-0000-0000-0000-000000000040', 'f9000000-0000-0000-0000-0000000000b2'),
|
||||
('f9000000-0000-0000-0000-000000000041', 'f9000000-0000-0000-0000-0000000000b1'),
|
||||
('f9000000-0000-0000-0000-000000000006', 'f9000000-0000-0000-0000-0000000000b3');
|
||||
|
||||
-- Ausgeblendet, zusammengeführt, privat
|
||||
insert into public.games (id, title, visibility, hidden_at) values
|
||||
('f9000000-0000-0000-0000-000000000030', 'Qxz Verborgen', 'catalog', now());
|
||||
insert into public.games (id, title, visibility, hidden_at, merged_into_id) values
|
||||
('f9000000-0000-0000-0000-000000000031', 'Qxz Verschmolzen', 'catalog', now(), 'f9000000-0000-0000-0000-000000000001');
|
||||
insert into public.games (id, title, visibility, owner_id) values
|
||||
('f9000000-0000-0000-0000-000000000032', 'Qxz Privat', 'private', '11111111-1111-1111-1111-111111111111'),
|
||||
('f9000000-0000-0000-0000-000000000033', 'QXZ ZORB', 'private', '22222222-2222-2222-2222-222222222222');
|
||||
insert into public.game_publishers (game_id, publisher_id) values
|
||||
('f9000000-0000-0000-0000-000000000030', 'f9000000-0000-0000-0000-0000000000b2'),
|
||||
('f9000000-0000-0000-0000-000000000033', 'f9000000-0000-0000-0000-0000000000b2');
|
||||
|
||||
-- Bilder: 50 mit Quelle ohne Erlaubnis (maskiert), 51 manuell gepflegt, 52 ohne Quelle
|
||||
insert into public.games (id, title, visibility, image_path, catalog_source_id, source_ref) values
|
||||
('f9000000-0000-0000-0000-000000000050', 'Qxz Cover Quelle', 'catalog', 'covers/quelle.png', (select id from public.catalog_sources where key = 'bgg'), '501'),
|
||||
('f9000000-0000-0000-0000-000000000051', 'Qxz Cover Manuell', 'catalog', 'covers/manuell.png', (select id from public.catalog_sources where key = 'bgg'), '502'),
|
||||
('f9000000-0000-0000-0000-000000000052', 'Qxz Cover Eigen', 'catalog', 'covers/eigen.png', null, null);
|
||||
update public.games set manual_fields = '{}' where id = 'f9000000-0000-0000-0000-000000000050';
|
||||
update public.games set manual_fields = array['image_path'] where id = 'f9000000-0000-0000-0000-000000000051';
|
||||
|
||||
-- 40 Spiele für das Nachladen (Kriterium 5)
|
||||
insert into public.games (id, title, visibility)
|
||||
select ('f9000000-0000-0000-0000-0000000001' || lpad(n::text, 2, '0'))::uuid, 'Qxz Seite ' || lpad(n::text, 2, '0'), 'catalog'
|
||||
from generate_series(1, 40) n;
|
||||
|
||||
-- Alternative Titel (als postgres, wie im Studio)
|
||||
insert into public.game_alt_titles (id, game_id, locale, title) values
|
||||
('f9000000-0000-0000-0000-0000000000a1', 'f9000000-0000-0000-0000-000000000010', 'de', 'Qxz Zugvogel'),
|
||||
('f9000000-0000-0000-0000-0000000000a2', 'f9000000-0000-0000-0000-000000000010', 'en', 'Qxz Migrant Bird'),
|
||||
('f9000000-0000-0000-0000-0000000000a3', 'f9000000-0000-0000-0000-000000000011', 'en', 'Qxz Bird'),
|
||||
('f9000000-0000-0000-0000-0000000000a4', 'f9000000-0000-0000-0000-000000000012', 'de', 'Qxz Einzigartig Alt'),
|
||||
('f9000000-0000-0000-0000-0000000000a5', 'f9000000-0000-0000-0000-000000000012', 'en', 'QXZ EINZIGARTIG ALT'),
|
||||
('f9000000-0000-0000-0000-0000000000a6', 'f9000000-0000-0000-0000-000000000030', 'de', 'Qxz Verborgen Alt'),
|
||||
('f9000000-0000-0000-0000-0000000000a7', 'f9000000-0000-0000-0000-000000000031', 'de', 'Qxz Verschmolzen Alt'),
|
||||
('f9000000-0000-0000-0000-0000000000a8', 'f9000000-0000-0000-0000-000000000032', 'de', 'Qxz Privat Alt'),
|
||||
('f9000000-0000-0000-0000-0000000000a9', 'f9000000-0000-0000-0000-000000000033', 'en', 'Qxz Bens Alt'),
|
||||
-- drei Treffer-Titel für "qxz nomad": Stufe 2, Stufe 1 (länger, weniger ähnlich), Stufe 1 (kürzer, ähnlicher)
|
||||
('f9000000-0000-0000-0000-0000000000c1', 'f9000000-0000-0000-0000-000000000017', 'de', 'Stern Qxz Nomad'),
|
||||
('f9000000-0000-0000-0000-0000000000c2', 'f9000000-0000-0000-0000-000000000017', 'en', 'Qxz Nomad Sternenwanderer'),
|
||||
('f9000000-0000-0000-0000-0000000000c3', 'f9000000-0000-0000-0000-000000000017', 'de', 'Qxz Nomadisch');
|
||||
|
||||
select is((select title_search from public.games where id = 'f9000000-0000-0000-0000-000000000014'), 'qxz aeon s end',
|
||||
'KAT-01 Kriterium 1: games.title_search wird beim Anlegen gefüllt');
|
||||
select is((select title_search from public.game_alt_titles where id = 'f9000000-0000-0000-0000-0000000000a5'), 'qxz einzigartig alt',
|
||||
'KAT-01 Kriterium 2: game_alt_titles.title_search wird gefüllt');
|
||||
select is((select name_search from public.publishers where id = 'f9000000-0000-0000-0000-0000000000b1'), 'qxz hersteller zwei',
|
||||
'KAT-01 Kriterium 3: publishers.name_search wird gefüllt');
|
||||
update public.publishers set name = 'Qxz Hersteller Zwei & Söhne' where id = 'f9000000-0000-0000-0000-0000000000b1';
|
||||
select is((select name_search from public.publishers where id = 'f9000000-0000-0000-0000-0000000000b1'), 'qxz hersteller zwei sohne',
|
||||
'KAT-01: publishers.name_search folgt einer Namensänderung');
|
||||
update public.publishers set name = 'Qxz Hersteller Zwei' where id = 'f9000000-0000-0000-0000-0000000000b1';
|
||||
|
||||
-- Hilfsfunktion: alle Fixture-Treffer durchblättern (nur als postgres, für Abfragen ohne Suchbegriff)
|
||||
create function pg_temp.hits(p_query text default null, p_players smallint default null, p_max_minutes smallint default null,
|
||||
p_age smallint default null, p_genre_ids uuid[] default null, p_mechanic_ids uuid[] default null)
|
||||
returns setof uuid language sql as $$
|
||||
select r.id
|
||||
from generate_series(0, (select coalesce(max(t.total_count), 0)
|
||||
from public.search_catalog_games(p_query, p_players, p_max_minutes, p_age, p_genre_ids, p_mechanic_ids, 1, 0) t)::int, 50) o,
|
||||
lateral public.search_catalog_games(p_query, p_players, p_max_minutes, p_age, p_genre_ids, p_mechanic_ids, 50, o) r
|
||||
where r.id::text like 'f9000000%'
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Kriterium 1: Teilzeichenkette, Groß/Klein, Sonderzeichen, Relevanzstufen
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select results_eq($$select id, match_rank::int, total_count::int from public.search_catalog_games('qxz zorb')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000001'::uuid, 0, 6),
|
||||
('f9000000-0000-0000-0000-000000000002', 1, 6),
|
||||
('f9000000-0000-0000-0000-000000000003', 2, 6),
|
||||
('f9000000-0000-0000-0000-000000000004', 3, 6),
|
||||
('f9000000-0000-0000-0000-000000000005', 3, 6),
|
||||
('f9000000-0000-0000-0000-000000000006', 4, 6)$$,
|
||||
'KAT-01 Kriterium 1: Relevanzstufen 0 bis 4 in der erwarteten Reihenfolge; gleiche Stufe nach Ähnlichkeit (nicht nach Titel); Verlagstreffer zuletzt');
|
||||
select results_eq($$select id from public.search_catalog_games('QXZ: ZORB!')$$,
|
||||
$$select id from public.search_catalog_games('qxz zorb')$$,
|
||||
'KAT-01 Kriterium 1: Groß-/Kleinschreibung und Sonderzeichen ändern das Ergebnis nicht');
|
||||
select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz zo')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000001'::uuid, 1), ('f9000000-0000-0000-0000-000000000002', 1),
|
||||
('f9000000-0000-0000-0000-000000000003', 2), ('f9000000-0000-0000-0000-000000000004', 3),
|
||||
('f9000000-0000-0000-0000-000000000005', 3), ('f9000000-0000-0000-0000-000000000006', 4)$$,
|
||||
'KAT-01 Kriterium 1: ein Teil des Titels genügt (Wort ab 2 Zeichen als Teilzeichenkette)');
|
||||
select results_eq($$select id from public.search_catalog_games('qxz twin')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000007'::uuid), ('f9000000-0000-0000-0000-000000000008')$$,
|
||||
'KAT-01 Kriterium 5: bei völligem Gleichstand entscheidet die ID (stabile Reihenfolge)');
|
||||
select results_eq($$select id from public.search_catalog_games('qxz zorb', null, null, null, null, null, 4, 0)$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000001'::uuid), ('f9000000-0000-0000-0000-000000000002'), ('f9000000-0000-0000-0000-000000000003'), ('f9000000-0000-0000-0000-000000000004')$$,
|
||||
'KAT-01 Kriterium 5: die erste Seite enthält die relevantesten Treffer (Stufe, dann Ähnlichkeit), nicht die ersten nach Titel');
|
||||
select results_eq($$select id from public.search_catalog_games('qxz zorb', null, null, null, null, null, 2, 0)
|
||||
union all select id from public.search_catalog_games('qxz zorb', null, null, null, null, null, 2, 2)
|
||||
union all select id from public.search_catalog_games('qxz zorb', null, null, null, null, null, 2, 4)$$,
|
||||
$$select id from public.search_catalog_games('qxz zorb')$$,
|
||||
'KAT-01 Kriterium 5: Seiten zu je zwei ergeben dieselbe Reihenfolge wie die ganze Liste');
|
||||
select results_eq($$select id from public.search_catalog_games('qxz twin', null, null, null, null, null, 1, 0)
|
||||
union all select id from public.search_catalog_games('qxz twin', null, null, null, null, null, 1, 1)$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000007'::uuid), ('f9000000-0000-0000-0000-000000000008')$$,
|
||||
'KAT-01 Kriterium 5: bei völligem Gleichstand entscheidet die ID auch über die Seitengrenze hinweg');
|
||||
select results_eq($$select id from public.search_catalog_games('qxz zwilling', null, null, null, null, null, 3, 0)
|
||||
union all select id from public.search_catalog_games('qxz zwilling', null, null, null, null, null, 3, 3)$$,
|
||||
$$select id from public.games where title = 'Qxz Zwilling' order by id$$,
|
||||
'KAT-01 Kriterium 5: sechs gleich gute Treffer erscheinen seitenübergreifend in ID-Reihenfolge');
|
||||
select results_eq($$select id, match_rank::int, matched_alt_title from public.search_catalog_games('qxz nomad')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000017'::uuid, 1, 'Qxz Nomadisch'::text)$$,
|
||||
'KAT-01 Kriterium 2: von mehreren treffenden alternativen Titeln gewinnt die beste Stufe, dann die größte Ähnlichkeit');
|
||||
select results_eq($$select id, match_rank::int, matched_alt_title from public.search_catalog_games('qxz einzig')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000012'::uuid, 1, null::text)$$,
|
||||
'KAT-01 Kriterium 2: bei gleicher Stufe von Haupttitel und alternativem Titel gilt der Haupttitel (matched_alt_title null)');
|
||||
select is((select count(*) from public.search_catalog_games('qxz käse')), 1::bigint, 'KAT-01 Kriterium 1: Umlaut im Suchbegriff findet den Umlaut-Titel');
|
||||
select is((select count(*) from public.search_catalog_games('QXZ KASE GROSSE')), 1::bigint, 'KAT-01 Kriterium 1: ohne Umlaut und ß wird derselbe Titel gefunden');
|
||||
select is((select count(*) from public.search_catalog_games('qxz grösse')), 1::bigint, 'KAT-01 Kriterium 1: ö und ß in der Eingabe');
|
||||
select is((select count(*) from public.search_catalog_games('qxz kaese')), 0::bigint, 'KAT-01 Spec-F5: "ae" findet "ä" in R1 nicht');
|
||||
select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz aeons end')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000014'::uuid, 0)$$,
|
||||
'KAT-01 Kriterium 1: "aeons end" findet "Æon’s End" über die Schreibweise ohne Leerzeichen');
|
||||
select is((select count(*) from public.search_catalog_games('qxzaeonsend')), 1::bigint, 'KAT-01 Kriterium 1: ohne jedes Leerzeichen eingegeben');
|
||||
select is((select count(*) from public.search_catalog_games('qxz æon’s end')), 1::bigint, 'KAT-01 Kriterium 1: Æ und Apostroph in der Eingabe');
|
||||
select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz 7wonders')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000015'::uuid, 0)$$,
|
||||
'KAT-01 Kriterium 1: "7wonders" findet "7 Wonders"');
|
||||
select results_eq($$select id, match_rank::int from public.search_catalog_games('Qxz-Catan!')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000016'::uuid, 0)$$,
|
||||
'KAT-01 Kriterium 1: "CATAN®" wird über "catan" gefunden');
|
||||
select is((select count(*) from public.search_catalog_games('!!!')), 0::bigint, 'KAT-01 Kriterium 1: nur Sonderzeichen ergibt keine Treffer');
|
||||
select is((select count(*) from public.search_catalog_games('zzznichtvorhanden')), 0::bigint, 'KAT-01 Kriterium 6: Suche ohne Treffer liefert keine Zeilen');
|
||||
select results_eq($$select id from public.search_catalog_games('qxz 9 zorb') order by id$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000001'::uuid), ('f9000000-0000-0000-0000-000000000002'), ('f9000000-0000-0000-0000-000000000003'),
|
||||
('f9000000-0000-0000-0000-000000000004'), ('f9000000-0000-0000-0000-000000000005'), ('f9000000-0000-0000-0000-000000000006')$$,
|
||||
'KAT-01 Kriterium 1: Wörter mit einem Zeichen werden verworfen ("qxz 9 zorb" = "qxz zorb")');
|
||||
select results_eq($$select id, match_rank::int from public.search_catalog_games('a b', null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[])$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000060'::uuid, 3)$$,
|
||||
'KAT-01 Kriterium 1: "a b" (nur Wörter mit einem Zeichen) trifft nur über die Schreibweise ohne Leerzeichen');
|
||||
select results_eq($$select id, match_rank::int from public.search_catalog_games('É', null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[])$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000060'::uuid, null::int), ('f9000000-0000-0000-0000-000000000005', null)$$,
|
||||
'KAT-01 Kriterium 1: ein normalisierter Begriff unter 2 Zeichen gilt als kein Suchbegriff (alles, match_rank null)');
|
||||
select results_eq($$select id, match_rank::int from public.search_catalog_games(' ', null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[])$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000060'::uuid, null::int), ('f9000000-0000-0000-0000-000000000005', null)$$,
|
||||
'KAT-01 Kriterium 8: nur Leerzeichen gelten als kein Suchbegriff');
|
||||
select results_eq($$select id, match_rank::int from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[])$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000060'::uuid, null::int), ('f9000000-0000-0000-0000-000000000005', null)$$,
|
||||
'KAT-01 Kriterium 8: null ist kein Suchbegriff, Sortierung nach Titel');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Kriterium 2: alternative Titel
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select results_eq($$select id, matched_alt_title, match_rank::int from public.search_catalog_games('qxz zugvogel')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000010'::uuid, 'Qxz Zugvogel'::text, 0)$$,
|
||||
'KAT-01 Kriterium 2: der deutsche alternative Titel findet das Spiel, matched_alt_title nennt ihn');
|
||||
select results_eq($$select id, matched_alt_title, match_rank::int from public.search_catalog_games('qxz migrant bird')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000010'::uuid, 'Qxz Migrant Bird'::text, 0)$$,
|
||||
'KAT-01 Kriterium 2: der englische alternative Titel findet dasselbe Spiel');
|
||||
select results_eq($$select id, matched_alt_title, match_rank::int from public.search_catalog_games('qxz hauptspiel')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000010'::uuid, null::text, 1)$$,
|
||||
'KAT-01 Kriterium 2: Treffer über den Haupttitel hat matched_alt_title null');
|
||||
select results_eq($$select id, matched_alt_title, match_rank::int, total_count::int from public.search_catalog_games('qxz bird')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000011'::uuid, 'Qxz Bird'::text, 0, 2),
|
||||
('f9000000-0000-0000-0000-000000000010', 'Qxz Migrant Bird', 3, 2)$$,
|
||||
'KAT-01 Kriterium 2: der alternative Titel zählt, wenn er besser trifft als der Haupttitel (Stufe 0 statt 2)');
|
||||
select results_eq($$select id, matched_alt_title, match_rank::int, total_count::int from public.search_catalog_games('qxz einzigartig')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000012'::uuid, null::text, 0, 1)$$,
|
||||
'KAT-01 Kriterium 2: ein Spiel erscheint nur einmal, auch wenn Haupt- und alternative Titel treffen; bei besserem Haupttitel null');
|
||||
select results_eq($$select id, lower(matched_alt_title), total_count::int from public.search_catalog_games('qxz einzigartig alt')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000012'::uuid, 'qxz einzigartig alt'::text, 1)$$,
|
||||
'KAT-01 Kriterium 2: zwei alternative Titel (de, en) mit gleicher Stufe ergeben eine Zeile');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Kriterium 3: Verlage
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select results_eq($$select id, match_rank::int, publisher_names from public.search_catalog_games('qxz hersteller zwei')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000040'::uuid, 4, array['Qxz Hersteller Eins', 'Qxz Hersteller Zwei']::text[]),
|
||||
('f9000000-0000-0000-0000-000000000041', 4, array['Qxz Hersteller Zwei'])$$,
|
||||
'KAT-01 Kriterium 3: der Verlag findet alle seine Spiele (Stufe 4); ein Spiel mit zwei Verlagen über beide; publisher_names sortiert und vollständig');
|
||||
select results_eq($$select id from public.search_catalog_games('qxz hersteller eins') order by id$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000001'::uuid), ('f9000000-0000-0000-0000-000000000040')$$,
|
||||
'KAT-01 Kriterium 3: der andere Verlag des Spiels findet es ebenfalls; ausgeblendete Spiele des Verlags fehlen');
|
||||
select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz zorb zorb eins')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000001'::uuid, 4)$$,
|
||||
'KAT-01 Kriterium 3: ein doppelt eingegebenes Wort zählt einfach (Verlagstreffer bleibt auffindbar)');
|
||||
select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz zorb eins')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000001'::uuid, 4)$$,
|
||||
'KAT-01 Kriterium 3: ein Wort aus dem Titel plus ein Wort aus dem Verlag (UND)');
|
||||
select results_eq($$select id, publisher_names from public.search_catalog_games('qxz cabin')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000060'::uuid, '{}'::text[])$$,
|
||||
'KAT-01 Kriterium 3: publisher_names ist leer statt null');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Kriterium 4 und 8: Filter, mit und ohne Suchbegriff
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select results_eq($$select h from pg_temp.hits(p_players := 3::smallint) h order by 1$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000021'), ('f9000000-0000-0000-0000-000000000024'), ('f9000000-0000-0000-0000-000000000025')$$,
|
||||
'KAT-01 Kriterium 8: Spielerzahl 3 ohne Suchbegriff (Spiele ohne Angabe fehlen, 2-2 passt nicht)');
|
||||
select results_eq($$select h from pg_temp.hits('qxz filter', p_players := 3::smallint) h order by 1$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000021'), ('f9000000-0000-0000-0000-000000000024'), ('f9000000-0000-0000-0000-000000000025')$$,
|
||||
'KAT-01 Kriterium 4: Spielerzahl 3 mit Suchbegriff');
|
||||
select results_eq($$select h from pg_temp.hits(p_players := 2::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 8: Spielerzahl 2 (untere und obere Grenze zählen mit)');
|
||||
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');
|
||||
select results_eq($$select h from pg_temp.hits(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 8: Alter 8 (Mindestalter bis einschließlich 8)');
|
||||
select results_eq($$select h from pg_temp.hits(p_age := 0::smallint) h$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000024'::uuid)$$,
|
||||
'KAT-01 Kriterium 4: Alter 0 findet nur Mindestalter 0');
|
||||
select results_eq($$select h from pg_temp.hits(p_genre_ids := array['f9000000-0000-0000-0000-0000000000e1']::uuid[]) h order by 1$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000021'), ('f9000000-0000-0000-0000-000000000022')$$,
|
||||
'KAT-01 Kriterium 4: Genre allein');
|
||||
select results_eq($$select h from pg_temp.hits(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: nur Spiele mit beiden');
|
||||
select results_eq($$select h from pg_temp.hits(p_genre_ids := array['f9000000-0000-0000-0000-0000000000e1', 'f9000000-0000-0000-0000-0000000000e1']::uuid[]) h order by 1$$,
|
||||
$$select h from pg_temp.hits(p_genre_ids := array['f9000000-0000-0000-0000-0000000000e1']::uuid[]) h order by 1$$,
|
||||
'KAT-01 Kriterium 4: Duplikate im Array zählen einfach');
|
||||
select results_eq($$select h from pg_temp.hits(p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d2']::uuid[]) h order by 1$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000021'::uuid), ('f9000000-0000-0000-0000-000000000025')$$,
|
||||
'KAT-01 Kriterium 4: Mechanik allein');
|
||||
select results_eq($$select h from pg_temp.hits(p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d2', 'f9000000-0000-0000-0000-0000000000d2']::uuid[]) h order by 1$$,
|
||||
$$select h from pg_temp.hits(p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d2']::uuid[]) h order by 1$$,
|
||||
'KAT-01 Kriterium 4: Duplikate im Mechanik-Array zählen einfach');
|
||||
select results_eq($$select h from pg_temp.hits(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: nur Spiele mit beiden');
|
||||
select results_eq($$select h from pg_temp.hits(p_players := 3::smallint, p_age := 12::smallint,
|
||||
p_genre_ids := array['f9000000-0000-0000-0000-0000000000e1']::uuid[], p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d2']::uuid[]) h$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000021'::uuid)$$,
|
||||
'KAT-01 Kriterium 4: alle Filter zusammen sind UND-verknüpft');
|
||||
select results_eq($$select h from pg_temp.hits('qxz filter', p_players := 2::smallint, p_max_minutes := 60::smallint,
|
||||
p_genre_ids := array['f9000000-0000-0000-0000-0000000000e2']::uuid[]) h order by 1$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000025')$$,
|
||||
'KAT-01 Kriterium 4: Suchbegriff plus Spielerzahl, Dauer und Genre');
|
||||
select is((select count(*) from public.search_catalog_games(null, null, null, null, array[gen_random_uuid()])), 0::bigint,
|
||||
'KAT-01 Kriterium 4: unbekannte Genre-ID ergibt 0 Treffer');
|
||||
select is((select count(*) from public.search_catalog_games(null, null, null, null, null, array[gen_random_uuid()])), 0::bigint,
|
||||
'KAT-01 Kriterium 4: unbekannte Mechanik-ID ergibt 0 Treffer');
|
||||
select results_eq($$select id, match_rank::int, matched_alt_title, total_count::int from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[])$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000060'::uuid, null::int, null::text, 2),
|
||||
('f9000000-0000-0000-0000-000000000005', null, null, 2)$$,
|
||||
'KAT-01 Kriterium 8: ohne Suchbegriff nach Titel sortiert, match_rank und matched_alt_title null');
|
||||
select results_eq($$select id from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[], null, 1, 0)
|
||||
union all select id from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[], null, 1, 1)$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000060'::uuid), ('f9000000-0000-0000-0000-000000000005')$$,
|
||||
'KAT-01 Kriterium 5: ohne Suchbegriff bestimmt der Titel (nicht die ID) auch die Seitengrenze');
|
||||
select results_eq($$select id from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e4']::uuid[])$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000080'::uuid), ('f9000000-0000-0000-0000-000000000081')$$,
|
||||
'KAT-01 Kriterium 8: ohne Suchbegriff wird binär nach dem normalisierten Titel sortiert (Leerzeichen vor Buchstaben)');
|
||||
select results_eq($$select h from pg_temp.hits() h order by 1$$,
|
||||
$$select id from public.games where id::text like 'f9000000%' and visibility = 'catalog' and hidden_at is null and merged_into_id is null order by id$$,
|
||||
'KAT-01 Kriterium 8: ohne Suchbegriff und ohne Filter erscheinen alle sichtbaren Fixture-Spiele (ohne ausgeblendete, zusammengeführte, private)');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Kriterium 5: Nachladen
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select results_eq($$select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 0)
|
||||
union all select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 15)
|
||||
union all select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 30)$$,
|
||||
$$select id from public.games where title like 'Qxz Seite %' order by title$$,
|
||||
'KAT-01 Kriterium 5: drei Seiten ergeben ohne Überschneidung genau die 40 Treffer in stabiler Reihenfolge');
|
||||
select results_eq($$select count(*), count(distinct id), count(distinct total_count), min(total_count)
|
||||
from (select * from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 0)
|
||||
union all select * from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 15)
|
||||
union all select * from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 30)) s$$,
|
||||
$$values (40::bigint, 40::bigint, 1::bigint, 40::bigint)$$,
|
||||
'KAT-01 Kriterium 5: total_count ist in jeder Zeile jeder Seite gleich (40)');
|
||||
select is((select count(*) from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 45)), 0::bigint,
|
||||
'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');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- KAT-05 Kriterium 4 und 10: ausgeblendet, zusammengeführt, privat, Bild
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select is((select count(*) from public.search_catalog_games('qxz verborgen')), 0::bigint, 'KAT-05 Kriterium 4: ein ausgeblendetes Spiel erscheint nicht über den Titel');
|
||||
select is((select count(*) from public.search_catalog_games('qxz verborgen alt')), 0::bigint, 'KAT-05 Kriterium 4: ... nicht über den alternativen Titel');
|
||||
select is((select count(*) from public.search_catalog_games('qxz verschmolzen alt')), 0::bigint, 'KAT-05 Kriterium 4: ein zusammengeführtes Spiel erscheint nicht über seinen alternativen Titel');
|
||||
select is((select count(*) from public.search_catalog_games('qxz verschmolzen')), 0::bigint, 'KAT-05 Kriterium 4: ... und nicht über den Titel');
|
||||
select is((select count(*) from public.search_catalog_games('qxz privat')), 0::bigint, 'KAT-01 Sicherheit: ein privates Spiel erscheint nie (Titel)');
|
||||
select is((select count(*) from public.search_catalog_games('qxz bens alt')), 0::bigint, 'KAT-01 Sicherheit: ... auch nicht über einen alternativen Titel');
|
||||
select is((select count(*) from public.search_catalog_games(null, null, null, null, null, null, 50, 0) where id::text in ('f9000000-0000-0000-0000-000000000030', 'f9000000-0000-0000-0000-000000000031', 'f9000000-0000-0000-0000-000000000032', 'f9000000-0000-0000-0000-000000000033')), 0::bigint,
|
||||
'KAT-05 Kriterium 4: ohne Suchbegriff fehlen ausgeblendete, zusammengeführte und private Spiele ebenso (erste Seite)');
|
||||
select is((select count(*) from pg_temp.hits() h where h in ('f9000000-0000-0000-0000-000000000030', 'f9000000-0000-0000-0000-000000000031', 'f9000000-0000-0000-0000-000000000032', 'f9000000-0000-0000-0000-000000000033')), 0::bigint,
|
||||
'KAT-05 Kriterium 4: ohne Suchbegriff fehlen ausgeblendete, zusammengeführte und private Spiele auch beim Durchblättern');
|
||||
select results_eq($$select id, image_path from public.search_catalog_games('qxz cover') order by id$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000050'::uuid, null::text), ('f9000000-0000-0000-0000-000000000051', 'covers/manuell.png'),
|
||||
('f9000000-0000-0000-0000-000000000052', 'covers/eigen.png')$$,
|
||||
'KAT-05 Kriterium 10: image_path kommt aus catalog_games (maskiert bei Quelle ohne Erlaubnis, sichtbar bei manueller Pflege und ohne Quelle)');
|
||||
select results_eq($$select title, year, min_players, max_players, min_minutes, max_minutes, min_age from public.search_catalog_games('qxz filter 20')$$,
|
||||
$$values ('Qxz Filter 20'::text, null::smallint, 2::smallint, 4::smallint, 30::smallint, 60::smallint, 8::smallint)$$,
|
||||
'KAT-01: die Ausgabe enthält Titel und Kurzangaben');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Eingabegrenzen
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select throws_ok($$select * from public.search_catalog_games(repeat('a', 101))$$, 'P0001', 'search_query_too_long', 'KAT-01: 101 Zeichen sind zu lang');
|
||||
select lives_ok($$select * from public.search_catalog_games(repeat('a', 100))$$, 'KAT-01: 100 Zeichen sind erlaubt');
|
||||
select throws_ok($$select * from public.search_catalog_games('aa bb cc dd ee ff gg hh ii jj kk')$$, 'P0001', 'search_query_too_long', 'KAT-01: 11 Wörter sind zu viel');
|
||||
select throws_ok($$select * from public.search_catalog_games('a b c d e f g h i j k')$$, 'P0001', 'search_query_too_long', 'KAT-01: 11 Wörter zählen auch, wenn sie nur ein Zeichen haben');
|
||||
select lives_ok($$select * from public.search_catalog_games('aa bb cc dd ee ff gg hh ii jj')$$, 'KAT-01: 10 Wörter sind erlaubt');
|
||||
select throws_ok($$select * from public.search_catalog_games(null, 0::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Spielerzahl 0');
|
||||
select throws_ok($$select * from public.search_catalog_games(null, 100::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Spielerzahl 100');
|
||||
select lives_ok($$select * from public.search_catalog_games(null, 1::smallint); select * from public.search_catalog_games(null, 99::smallint)$$, 'KAT-01: Spielerzahl 1 und 99 sind erlaubt');
|
||||
select throws_ok($$select * from public.search_catalog_games(null, null, 0::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Dauer 0');
|
||||
select throws_ok($$select * from public.search_catalog_games(null, null, 1441::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Dauer 1441');
|
||||
select lives_ok($$select * from public.search_catalog_games(null, null, 1::smallint); select * from public.search_catalog_games(null, null, 1440::smallint)$$, 'KAT-01: Dauer 1 und 1440 sind erlaubt');
|
||||
select throws_ok($$select * from public.search_catalog_games(null, null, null, (-1)::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Alter -1');
|
||||
select throws_ok($$select * from public.search_catalog_games(null, null, null, 100::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Alter 100');
|
||||
select lives_ok($$select * from public.search_catalog_games(null, null, null, 0::smallint); select * from public.search_catalog_games(null, null, null, 99::smallint)$$, 'KAT-01: Alter 0 und 99 sind erlaubt');
|
||||
select throws_ok($$select * from public.search_catalog_games(null, null, null, null, (select array_agg(gen_random_uuid()) from generate_series(1, 21)))$$, 'P0001', 'search_filter_invalid', 'KAT-01: 21 Genre-IDs');
|
||||
select throws_ok($$select * from public.search_catalog_games(null, null, null, null, null, (select array_agg(gen_random_uuid()) from generate_series(1, 21)))$$, 'P0001', 'search_filter_invalid', 'KAT-01: 21 Mechanik-IDs');
|
||||
select lives_ok($$select * from public.search_catalog_games(null, null, null, null, (select array_agg(gen_random_uuid()) from generate_series(1, 20)), (select array_agg(gen_random_uuid()) from generate_series(1, 20)))$$, 'KAT-01: je 20 IDs sind erlaubt');
|
||||
select throws_ok($$select * from public.search_catalog_games(null, null, null, null, null, null, 0, 0)$$, 'P0001', 'search_paging_invalid', 'KAT-01: Seitengröße 0');
|
||||
select throws_ok($$select * from public.search_catalog_games(null, null, null, null, null, null, 51, 0)$$, 'P0001', 'search_paging_invalid', 'KAT-01: Seitengröße 51');
|
||||
select throws_ok($$select * from public.search_catalog_games(null, null, null, null, null, null, 30, -1)$$, 'P0001', 'search_paging_invalid', 'KAT-01: Versatz -1');
|
||||
select throws_ok($$select * from public.search_catalog_games(null, null, null, null, null, null, 30, 10001)$$, 'P0001', 'search_paging_invalid', 'KAT-01: Versatz 10001');
|
||||
select lives_ok($$select * from public.search_catalog_games(null, null, null, null, null, null, 1, 0); select * from public.search_catalog_games(null, null, null, null, null, null, 50, 10000)$$, 'KAT-01: Seitengröße 1 und 50, Versatz 0 und 10000 sind erlaubt');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Tabelle game_alt_titles: Checks, Unique, manual_fields, Kaskade
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'fr', 'Titre')$$,
|
||||
'23514', null, 'KAT-01 Kriterium 2: Sprache nur de oder en (Check)');
|
||||
select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', repeat('x', 201))$$,
|
||||
'23514', null, 'KAT-01 Kriterium 2: Titel höchstens 200 Zeichen (Check)');
|
||||
select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', '')$$,
|
||||
'23514', null, 'KAT-01 Kriterium 2: Titel nicht leer (Check)');
|
||||
select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', '!!! ®')$$,
|
||||
'23514', null, 'KAT-01 Kriterium 2: ein Titel nur aus Sonderzeichen ist nicht zulässig (title_search nicht null)');
|
||||
select lives_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', repeat('x', 200))$$,
|
||||
'KAT-01 Kriterium 2: 200 Zeichen sind erlaubt');
|
||||
select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000012', 'de', 'QXZ einzigartig alt!')$$,
|
||||
'23505', null, 'KAT-01 Kriterium 2: derselbe Titel in derselben Sprache nur einmal, unabhängig von der Schreibweise (Unique)');
|
||||
select lives_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', 'Qxz Zweitname')$$,
|
||||
'KAT-01 Kriterium 2: weiterer Titel in einer Sprache');
|
||||
select lives_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'en', 'QXZ zweitname!')$$,
|
||||
'KAT-01 Kriterium 2: derselbe Titel in der anderen Sprache ist zulässig');
|
||||
select lives_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', 'Qxz Dritter Name')$$,
|
||||
'KAT-01 Kriterium 2: mehrere Titel je Sprache sind erlaubt');
|
||||
select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values (gen_random_uuid(), 'de', 'Qxz Waise')$$,
|
||||
'23503', null, 'KAT-01 Kriterium 2: alternative Titel brauchen ein vorhandenes Spiel (Fremdschlüssel)');
|
||||
select throws_ok($$insert into public.game_alt_titles (game_id, locale, title, title_search) values ('f9000000-0000-0000-0000-000000000013', 'de', 'Qxz Eigen', 'x')$$,
|
||||
'428C9', null, 'KAT-01: title_search ist generiert und nicht beschreibbar (auch nicht als postgres)');
|
||||
select throws_ok($$update public.games set title_search = 'x' where id = 'f9000000-0000-0000-0000-000000000013'$$,
|
||||
'428C9', null, 'KAT-01: games.title_search ist nicht beschreibbar');
|
||||
|
||||
-- manual_fields: Einfügen, Ändern und Löschen markieren alt_titles (nur Katalogspiele)
|
||||
select ok((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000010'),
|
||||
'KAT-01 Kriterium 2: Einfügen alternativer Titel markiert alt_titles in manual_fields (Katalogspiel)');
|
||||
select ok(not ((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000040')),
|
||||
'KAT-01: ein Spiel ohne alternative Titel ist nicht als alt_titles markiert');
|
||||
select is((select manual_fields from public.games where id = 'f9000000-0000-0000-0000-000000000032'), '{}'::text[],
|
||||
'KAT-01: alternative Titel eines privaten Spiels markieren nichts');
|
||||
insert into public.games (id, title, visibility) values ('f9000000-0000-0000-0000-000000000071', 'Qxz Markierung', 'catalog');
|
||||
select ok(not ((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000071')), 'Ausgangslage: Markierungsspiel ohne alt_titles');
|
||||
insert into public.game_alt_titles (id, game_id, locale, title) values ('f9000000-0000-0000-0000-0000000000b9', 'f9000000-0000-0000-0000-000000000071', 'de', 'Qxz Mark Alt');
|
||||
select ok((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000071'), 'KAT-01: insert markiert alt_titles');
|
||||
update public.games set manual_fields = array_remove(manual_fields, 'alt_titles') where id = 'f9000000-0000-0000-0000-000000000071';
|
||||
update public.game_alt_titles set title = 'Qxz Mark Alt Zwei' where id = 'f9000000-0000-0000-0000-0000000000b9';
|
||||
select ok((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000071'), 'KAT-01: update markiert alt_titles');
|
||||
update public.games set manual_fields = array_remove(manual_fields, 'alt_titles') where id = 'f9000000-0000-0000-0000-000000000071';
|
||||
delete from public.game_alt_titles where id = 'f9000000-0000-0000-0000-0000000000b9';
|
||||
select ok((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000071'), 'KAT-01: delete markiert alt_titles');
|
||||
|
||||
-- manual_fields-Check: vollständige Liste von main plus alt_titles
|
||||
select lives_ok($$update public.games set manual_fields = array['title', 'description', 'description_locale', 'year',
|
||||
'min_players', 'max_players', 'min_minutes', 'max_minutes', 'min_age', 'image_path',
|
||||
'genres', 'mechanics', 'categories', 'publishers', 'alt_titles'] where id = 'f9000000-0000-0000-0000-000000000071'$$,
|
||||
'KAT-01: der Check akzeptiert alle 14 bisherigen Schlüssel und alt_titles');
|
||||
select throws_ok($$update public.games set manual_fields = array['alt_titles', 'unbekannt'] where id = 'f9000000-0000-0000-0000-000000000071'$$,
|
||||
'23514', null, 'KAT-01: der Check lehnt unbekannte Schlüssel weiterhin ab');
|
||||
|
||||
-- Kaskade: Spiel löschen löscht seine alternativen Titel
|
||||
insert into public.games (id, title, visibility) values ('f9000000-0000-0000-0000-000000000070', 'Qxz Löschtest', 'catalog');
|
||||
insert into public.game_alt_titles (game_id, locale, title) values
|
||||
('f9000000-0000-0000-0000-000000000070', 'de', 'Qxz Löschtest Alt'), ('f9000000-0000-0000-0000-000000000070', 'en', 'Qxz Delete test');
|
||||
delete from public.games where id = 'f9000000-0000-0000-0000-000000000070';
|
||||
select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000070'), 0::bigint,
|
||||
'KAT-01 Kriterium 2: Spiel löschen löscht seine alternativen Titel');
|
||||
|
||||
-- updated_at: Trigger setzt ihn bei Änderung (ctid statt now(), die Transaktion hat eine feste Zeit)
|
||||
select ok(exists (select 1 from pg_trigger where tgrelid = 'public.game_alt_titles'::regclass and tgname = 'set_updated_at'),
|
||||
'KAT-01: game_alt_titles setzt updated_at per Trigger');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Rollen: anon, authenticated (Sicherheit Kriterium 2)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
set local role anon;
|
||||
select lives_ok($$select * from public.search_catalog_games('qxz zorb')$$, 'KAT-01 Sicherheit Kriterium 2: die Suche als anon läuft ohne Rechtefehler');
|
||||
select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz zorb')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000001'::uuid, 0), ('f9000000-0000-0000-0000-000000000002', 1),
|
||||
('f9000000-0000-0000-0000-000000000003', 2), ('f9000000-0000-0000-0000-000000000004', 3),
|
||||
('f9000000-0000-0000-0000-000000000005', 3), ('f9000000-0000-0000-0000-000000000006', 4)$$,
|
||||
'KAT-01 Sicherheit Kriterium 2: anon sieht dieselben Treffer; das private Spiel "QXZ ZORB" eines Nutzers fehlt');
|
||||
select results_eq($$select id, matched_alt_title from public.search_catalog_games('qxz zugvogel')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000010'::uuid, 'Qxz Zugvogel'::text)$$, 'KAT-01 Kriterium 2: alternative Titel funktionieren für anon');
|
||||
select results_eq($$select id, publisher_names from public.search_catalog_games('qxz doppelspiel')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000040'::uuid, array['Qxz Hersteller Eins', 'Qxz Hersteller Zwei']::text[])$$,
|
||||
'KAT-01 Kriterium 3: Verlage funktionieren für anon');
|
||||
select results_eq($$select id, image_path from public.search_catalog_games('qxz cover') order by id$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000050'::uuid, null::text), ('f9000000-0000-0000-0000-000000000051', 'covers/manuell.png'),
|
||||
('f9000000-0000-0000-0000-000000000052', 'covers/eigen.png')$$, 'KAT-05 Kriterium 10: Maskierung des Bildes gilt für anon');
|
||||
select is((select count(*) from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e1', 'f9000000-0000-0000-0000-0000000000e2']::uuid[])), 1::bigint,
|
||||
'KAT-01 Kriterium 4: Filter wirken auch für anon');
|
||||
select is((select count(*) from public.search_catalog_games('qxz seite', p_limit := 50)), 40::bigint, 'KAT-01 Kriterium 5: Nachladen für anon');
|
||||
select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000010'), 2::bigint,
|
||||
'KAT-01 Kriterium 2: anon liest alternative Titel von Katalogspielen');
|
||||
select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000032'), 0::bigint,
|
||||
'KAT-01 Kriterium 2: anon liest keine alternativen Titel eines privaten Spiels');
|
||||
select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', 'Qxz Anon')$$,
|
||||
'42501', null, 'KAT-01 Kriterium 2: anon kann keine alternativen Titel anlegen');
|
||||
select throws_ok($$update public.game_alt_titles set title = 'Qxz Anon' where id = 'f9000000-0000-0000-0000-0000000000a1'$$,
|
||||
'42501', null, 'KAT-01 Kriterium 2: anon kann keine alternativen Titel ändern');
|
||||
select throws_ok($$delete from public.game_alt_titles where id = 'f9000000-0000-0000-0000-0000000000a1'$$,
|
||||
'42501', null, 'KAT-01 Kriterium 2: anon kann keine alternativen Titel löschen');
|
||||
reset role;
|
||||
|
||||
-- Anna (authentifiziert): legt ein privates Spiel an und ändert es (title_search wird mit den Rechten des Nutzers berechnet)
|
||||
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
|
||||
set local role authenticated;
|
||||
select lives_ok($$insert into public.games (owner_id, title, visibility) values ('11111111-1111-1111-1111-111111111111', 'Qxz Mölkky ®', 'private')$$,
|
||||
'KAT-01 Sicherheit: authenticated legt ein privates Spiel an (search_normalize ausführbar, kein 42501)');
|
||||
select is((select title_search from public.games where title = 'Qxz Mölkky ®'), 'qxz molkky',
|
||||
'KAT-01 Sicherheit: title_search ist nach dem Anlegen gefüllt');
|
||||
select is((select count(*) from public.search_catalog_games('qxz molkky')), 0::bigint, 'KAT-01 Sicherheit: Anna findet das gerade angelegte private Spiel nicht über die Katalogsuche');
|
||||
select lives_ok($$update public.games set title = 'Spiel™ ½' where title = 'Qxz Mölkky ®'$$,
|
||||
'KAT-01 Sicherheit: authenticated ändert den Titel seines privaten Spiels (kein 42501)');
|
||||
select is((select title_search from public.games where title = 'Spiel™ ½'), 'spiel 1 2',
|
||||
'KAT-01 Sicherheit: title_search ist nach dem Ändern aktualisiert');
|
||||
select throws_ok($$insert into public.games (owner_id, title, visibility, title_search) values ('11111111-1111-1111-1111-111111111111', 'Qxz X', 'private', 'x')$$,
|
||||
'428C9', null, 'KAT-01: authenticated kann title_search nicht setzen (generierte Spalte)');
|
||||
select ok(not has_column_privilege('authenticated', 'public.games', 'title_search', 'insert')
|
||||
and not has_column_privilege('authenticated', 'public.games', 'title_search', 'update')
|
||||
and not has_column_privilege('anon', 'public.games', 'title_search', 'update')
|
||||
and has_column_privilege('anon', 'public.games', 'title_search', 'select'),
|
||||
'KAT-01: title_search ist für API-Rollen nur lesbar (Spaltenrechte)');
|
||||
select lives_ok($$select * from public.search_catalog_games('qxz zorb')$$, 'KAT-01: die Suche als authenticated läuft');
|
||||
select results_eq($$select id from public.search_catalog_games('qxz zorb')$$,
|
||||
$$values ('f9000000-0000-0000-0000-000000000001'::uuid), ('f9000000-0000-0000-0000-000000000002'), ('f9000000-0000-0000-0000-000000000003'),
|
||||
('f9000000-0000-0000-0000-000000000004'), ('f9000000-0000-0000-0000-000000000005'), ('f9000000-0000-0000-0000-000000000006')$$,
|
||||
'KAT-01 Sicherheit: Annas Suche zeigt nur Katalogspiele (Bens privates "QXZ ZORB" fehlt)');
|
||||
select is((select count(*) from public.search_catalog_games('qxz privat')), 0::bigint, 'KAT-01 Sicherheit: Anna findet ihr eigenes privates Spiel nicht über die Katalogsuche');
|
||||
select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000032'), 1::bigint, 'KAT-01 Kriterium 2: Anna liest die alternativen Titel ihres privaten Spiels');
|
||||
select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000033'), 0::bigint, 'KAT-01 Kriterium 2: Anna liest nicht die alternativen Titel von Bens privatem Spiel');
|
||||
select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000032', 'de', 'Qxz Anna Neu')$$,
|
||||
'42501', null, 'KAT-01 Kriterium 2: auch am eigenen privaten Spiel kann Anna keine alternativen Titel anlegen');
|
||||
select throws_ok($$update public.game_alt_titles set title = 'Qxz Anna' where id = 'f9000000-0000-0000-0000-0000000000a8'$$,
|
||||
'42501', null, 'KAT-01 Kriterium 2: ... nicht ändern');
|
||||
select throws_ok($$delete from public.game_alt_titles where id = 'f9000000-0000-0000-0000-0000000000a8'$$,
|
||||
'42501', null, 'KAT-01 Kriterium 2: ... nicht löschen');
|
||||
reset role;
|
||||
|
||||
-- Ben: die eigene Suche zeigt Annas privates Spiel nicht und sieht keine fremden alternativen Titel
|
||||
select set_config('request.jwt.claims', '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', true);
|
||||
set local role authenticated;
|
||||
select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000032'), 0::bigint, 'KAT-01 Kriterium 2: Ben liest keine alternativen Titel von Annas privatem Spiel');
|
||||
select is((select count(*) from public.search_catalog_games('qxz privat')), 0::bigint, 'KAT-01 Sicherheit: Ben findet Annas privates Spiel nicht');
|
||||
select is((select count(*) from public.search_catalog_games('qxz zorb')), 6::bigint, 'KAT-01 Sicherheit: Ben findet sein eigenes privates "QXZ ZORB" nicht, nur die sechs Katalogspiele');
|
||||
reset role;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Startbestand-Werkzeug: alternative Titel, Idempotenz (Spec 4.3, Krit. 2)
|
||||
-- Testspiel B hat laut testdata/startbestand.csv zwei alternative Titel, Testspiel A keinen.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
\ir ../support/vocabulary_fixture.inc
|
||||
\ir ../support/startbestand_fixture.inc
|
||||
|
||||
select results_eq($$select a.locale, a.title from public.game_alt_titles a join public.games g on g.id = a.game_id
|
||||
where g.title = 'Testspiel B' order by a.locale$$,
|
||||
$$values ('de'::text, 'Testspiel Bravo'::text), ('en', 'Test Game Bravo')$$,
|
||||
'KAT-01 Kriterium 2: Testspiel B bekommt aus der CSV einen deutschen und einen englischen alternativen Titel');
|
||||
select is((select count(*) from public.game_alt_titles a join public.games g on g.id = a.game_id where g.title = 'Testspiel A'), 0::bigint,
|
||||
'KAT-01 Kriterium 2: Testspiel A hat keine alternativen Titel');
|
||||
select ok((select 'alt_titles' = any(manual_fields) from public.games where title = 'Testspiel B'),
|
||||
'KAT-01 Kriterium 2: das Einspielen markiert alt_titles in manual_fields (SB8)');
|
||||
select results_eq($$select id, match_rank::int, matched_alt_title from public.search_catalog_games('test game bravo')
|
||||
where id = extensions.uuid_generate_v5('6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4', 'game:testspiel-b')$$,
|
||||
$$values (extensions.uuid_generate_v5('6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4', 'game:testspiel-b'), 0, 'Test Game Bravo'::text)$$,
|
||||
'KAT-01 Kriterium 2: ein eingespielter alternativer Titel ist über die Suche auffindbar');
|
||||
|
||||
create temp table snap_alt on commit drop as
|
||||
select a.id, a.game_id, a.locale, a.title, a.ctid::text as c, a.updated_at as u from public.game_alt_titles a
|
||||
where a.game_id in (select id from public.games where title like 'Testspiel %');
|
||||
\ir ../support/startbestand_fixture.inc
|
||||
select is((select count(*) from snap_alt), 2::bigint, 'Schnappschuss der alternativen Titel enthält die zwei Zeilen von Testspiel B');
|
||||
select is_empty($$select 1 from snap_alt s where not exists (
|
||||
select 1 from public.game_alt_titles n where n.id = s.id and n.game_id = s.game_id and n.locale = s.locale
|
||||
and n.title = s.title and n.ctid::text = s.c and n.updated_at = s.u)$$,
|
||||
'KAT-01 Kriterium 2: erneutes Einspielen ändert keine Zeile von game_alt_titles (ctid und updated_at gleich)');
|
||||
select is((select count(*) from public.game_alt_titles a join public.games g on g.id = a.game_id where g.title like 'Testspiel %'), 2::bigint,
|
||||
'KAT-01 Kriterium 2: erneutes Einspielen legt keine Duplikate an');
|
||||
|
||||
-- Ein in der CSV nicht (mehr) stehender Titel wird gelöscht, auch bei Testspiel A ohne alt_titles; eine geänderte
|
||||
-- Schreibweise stellt nur den Titel wieder her (gleiche ID), unveränderte Zeilen bleiben unberührt.
|
||||
insert into public.game_alt_titles (game_id, locale, title)
|
||||
select id, 'en', 'Entfernter Titel' from public.games where title = 'Testspiel B';
|
||||
insert into public.game_alt_titles (game_id, locale, title)
|
||||
select id, 'de', 'Fremder Titel' from public.games where title = 'Testspiel A';
|
||||
update public.game_alt_titles set title = 'TESTSPIEL BRAVO' where locale = 'de' and game_id = (select id from public.games where title = 'Testspiel B');
|
||||
create temp table snap_alt2 on commit drop as
|
||||
select a.id, a.locale, a.title, a.ctid::text as c from public.game_alt_titles a where a.game_id in (select id from public.games where title like 'Testspiel %');
|
||||
\ir ../support/startbestand_fixture.inc
|
||||
select results_eq($$select a.locale, a.title from public.game_alt_titles a where a.game_id in (select id from public.games where title like 'Testspiel %') order by a.locale$$,
|
||||
$$values ('de'::text, 'Testspiel Bravo'::text), ('en', 'Test Game Bravo')$$,
|
||||
'KAT-01 Kriterium 2: ein in der CSV entfernter alternativer Titel wird gelöscht (auch bei einem Spiel ohne alt_titles); geänderte Schreibweise wird wiederhergestellt');
|
||||
select is((select count(*) from public.game_alt_titles a join snap_alt2 s on s.id = a.id and s.title = 'TESTSPIEL BRAVO'), 1::bigint,
|
||||
'KAT-01 Kriterium 2: die geänderte Schreibweise behält ihre ID (nur title aktualisiert)');
|
||||
select is((select count(*) from public.game_alt_titles a join snap_alt2 s on s.id = a.id and s.locale = 'en' and s.title = 'Test Game Bravo' and s.c = a.ctid::text), 1::bigint,
|
||||
'KAT-01 Kriterium 2: die unveränderte Zeile bleibt unberührt (gleiche ctid)');
|
||||
select is((select count(*) from public.game_alt_titles a join snap_alt2 s on s.id = a.id and s.locale = 'de' and s.c <> a.ctid::text), 1::bigint,
|
||||
'KAT-01 Kriterium 2: nur die geänderte Zeile wurde geschrieben');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- SQL-Bausteine aus docs/katalog-pflege.md (unverändert, ohne begin/commit)
|
||||
-- Abschnitt 3.1: Alternative Titel pflegen (Spiel 7a1b...0001 "Beispielspiel Runbook")
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
insert into public.games (id, title, year, min_players, max_players, min_minutes, max_minutes, min_age, description, description_locale, visibility)
|
||||
values ('7a1b2c3d-0000-4000-8000-000000000001', 'Beispielspiel Runbook', 2020, 2, 4, 30, 60, 8, 'Eigener Kurztext.', 'de', 'catalog');
|
||||
|
||||
insert into public.game_alt_titles (game_id, locale, title)
|
||||
values ('7a1b2c3d-0000-4000-8000-000000000001', 'de', 'Beispielspiel Deutsch'),
|
||||
('7a1b2c3d-0000-4000-8000-000000000001', 'en', 'Example game')
|
||||
on conflict (game_id, locale, title_search) do update set title = excluded.title
|
||||
where public.game_alt_titles.title is distinct from excluded.title;
|
||||
|
||||
select results_eq($$select locale, title from public.game_alt_titles
|
||||
where game_id = '7a1b2c3d-0000-4000-8000-000000000001' order by locale, title$$,
|
||||
$$values ('de'::text, 'Beispielspiel Deutsch'::text), ('en', 'Example game')$$,
|
||||
'Runbook 3.1: Alternative Titel anlegen');
|
||||
insert into public.game_alt_titles (game_id, locale, title)
|
||||
values ('7a1b2c3d-0000-4000-8000-000000000001', 'de', 'BEISPIELSPIEL DEUTSCH'),
|
||||
('7a1b2c3d-0000-4000-8000-000000000001', 'en', 'Example game')
|
||||
on conflict (game_id, locale, title_search) do update set title = excluded.title
|
||||
where public.game_alt_titles.title is distinct from excluded.title;
|
||||
select results_eq($$select locale, title from public.game_alt_titles
|
||||
where game_id = '7a1b2c3d-0000-4000-8000-000000000001' order by locale, title$$,
|
||||
$$values ('de'::text, 'BEISPIELSPIEL DEUTSCH'::text), ('en', 'Example game')$$,
|
||||
'Runbook 3.1: derselbe Baustein mit geänderter Schreibweise aktualisiert den Titel statt einen zweiten anzulegen');
|
||||
select results_eq($$select id, match_rank::int, matched_alt_title from public.search_catalog_games('example game')$$,
|
||||
$$values ('7a1b2c3d-0000-4000-8000-000000000001'::uuid, 0, 'Example game'::text)$$,
|
||||
'Runbook 3.1: der angelegte Titel ist sofort über die Suche auffindbar');
|
||||
|
||||
delete from public.game_alt_titles
|
||||
where game_id = '7a1b2c3d-0000-4000-8000-000000000001' and locale = 'en' and title = 'Example game';
|
||||
select results_eq($$select locale from public.game_alt_titles where game_id = '7a1b2c3d-0000-4000-8000-000000000001'$$,
|
||||
$$values ('de'::text)$$, 'Runbook 3.1: Alternativen Titel löschen');
|
||||
|
||||
-- Abschnitt 5, Schritt 2b: alternative Titel der Quelle am Ziel ergänzen, danach zusammenführen
|
||||
insert into public.games (id, title, year, min_players, max_players, min_minutes, max_minutes, min_age, description, description_locale, visibility) values
|
||||
('7a1b2c3d-0000-4000-8000-000000000002', 'Duplikat Runbook', 2020, 2, 4, 30, 60, 8, 'Eigener Kurztext.', 'de', 'catalog'),
|
||||
('7a1b2c3d-0000-4000-8000-000000000003', 'Ziel Runbook', 2020, 2, 4, 30, 60, 8, 'Eigener Kurztext.', 'de', 'catalog');
|
||||
insert into public.game_alt_titles (game_id, locale, title) values
|
||||
('7a1b2c3d-0000-4000-8000-000000000002', 'de', 'Runbook Quellname'),
|
||||
('7a1b2c3d-0000-4000-8000-000000000002', 'en', 'Runbook source name'),
|
||||
('7a1b2c3d-0000-4000-8000-000000000003', 'en', 'RUNBOOK SOURCE NAME');
|
||||
|
||||
select locale, title from public.game_alt_titles
|
||||
where game_id = '7a1b2c3d-0000-4000-8000-000000000002' order by locale, title;
|
||||
|
||||
insert into public.game_alt_titles (game_id, locale, title)
|
||||
select '7a1b2c3d-0000-4000-8000-000000000003', a.locale, a.title
|
||||
from public.game_alt_titles a
|
||||
where a.game_id = '7a1b2c3d-0000-4000-8000-000000000002'
|
||||
on conflict (game_id, locale, title_search) do nothing;
|
||||
|
||||
select results_eq($$select locale, title from public.game_alt_titles where game_id = '7a1b2c3d-0000-4000-8000-000000000003' order by locale, title$$,
|
||||
$$values ('de'::text, 'Runbook Quellname'::text), ('en', 'RUNBOOK SOURCE NAME')$$,
|
||||
'Runbook 5 Schritt 2b: Titel der Quelle werden am Ziel ergänzt, vorhandene (auch in anderer Schreibweise) bleiben unverändert');
|
||||
select results_eq($$select id from public.search_catalog_games('runbook quellname')$$,
|
||||
$$values ('7a1b2c3d-0000-4000-8000-000000000002'::uuid), ('7a1b2c3d-0000-4000-8000-000000000003')$$,
|
||||
'Runbook 5 Schritt 2b: das Ziel ist jetzt über den Titel der Quelle auffindbar (vor dem Zusammenführen zusammen mit der Quelle)');
|
||||
|
||||
select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003');
|
||||
|
||||
select is((select count(*) from public.game_alt_titles where game_id = '7a1b2c3d-0000-4000-8000-000000000002'), 2::bigint,
|
||||
'Spec-F6 A: nach dem Zusammenführen bleiben die alternativen Titel am ausgeblendeten Quell-Eintrag');
|
||||
select is((select count(*) from public.game_alt_titles where game_id = '7a1b2c3d-0000-4000-8000-000000000003'), 2::bigint,
|
||||
'Spec-F6 A: das Ziel behält seine alternativen Titel');
|
||||
select results_eq($$select id from public.search_catalog_games('runbook quellname')$$,
|
||||
$$values ('7a1b2c3d-0000-4000-8000-000000000003'::uuid)$$, 'Spec-F6 A: nach dem Zusammenführen findet die Suche nur noch das Ziel, nie die Quelle');
|
||||
select is((select count(*) from public.search_catalog_games('duplikat runbook')), 0::bigint, 'KAT-05 Kriterium 4: die zusammengeführte Quelle erscheint nicht mehr');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Kaskade beim Konto-Löschen (QUE-04): alternative Titel privater Spiele verschwinden mit dem Konto
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000033'), 1::bigint, 'Ausgangslage: Bens privates Spiel hat einen alternativen Titel');
|
||||
delete from auth.users where id = '22222222-2222-2222-2222-222222222222';
|
||||
select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000033'), 0::bigint,
|
||||
'KAT-01: Konto löschen löscht auch die alternativen Titel seiner privaten Spiele');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Abschnitt 8 des Runbooks: Normalisierung nach einem Image-Upgrade prüfen (R6)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select results_eq($$select 'games' as tabelle, count(*) as abweichend from public.games
|
||||
where title_search is distinct from public.search_normalize(title)
|
||||
union all select 'game_alt_titles', count(*) from public.game_alt_titles
|
||||
where title_search is distinct from public.search_normalize(title)
|
||||
union all select 'publishers', count(*) from public.publishers
|
||||
where name_search is distinct from public.search_normalize(name)$$,
|
||||
$$values ('games'::text, 0::bigint), ('game_alt_titles', 0), ('publishers', 0)$$,
|
||||
'Runbook 8: die Prüfabfrage meldet bei unverändertem Stand überall 0 Abweichungen');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Abschnitt 8.2 des Runbooks: Betrieb (Prüfabfragen unverändert, dazu die Annahmen für QUE-04)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select set_eq($$select rolname, rolconfig from pg_roles where rolname in ('anon', 'authenticated')$$,
|
||||
$$select rolname, rolconfig from pg_roles where rolname in ('anon', 'authenticated') and exists (select 1 from unnest(rolconfig) c where c like 'statement_timeout=%')$$,
|
||||
'Runbook 8.2: anon und authenticated haben ein statement_timeout in rolconfig');
|
||||
select is((select count(*) from pg_available_extensions where name in ('pg_trgm', 'unaccent')), 2::bigint,
|
||||
'Runbook 8.2: pg_trgm und unaccent sind verfügbar (select * from pg_available_extensions …)');
|
||||
select is((select count(*) from pg_settings where name in ('log_min_duration_statement', 'log_statement', 'log_parameter_max_length', 'log_parameter_max_length_on_error', 'auto_explain.log_min_duration', 'pgaudit.log', 'pgaudit.log_parameter')), 7::bigint,
|
||||
'Runbook 8.2: die sieben Protokoll-Einstellungen der Prüfabfrage sind abfragbar');
|
||||
select is((select setting from pg_settings where name = 'log_parameter_max_length_on_error'), '0',
|
||||
'QUE-04: bei Fehlern werden keine Parameterwerte protokolliert (Suchbegriffe im JSON-Parameter von PostgREST)');
|
||||
select ok((select setting from pg_settings where name = 'log_statement') in ('none', 'ddl')
|
||||
and (select setting from pg_settings where name = 'log_min_duration_statement') = '-1'
|
||||
and (select setting from pg_settings where name = 'pgaudit.log') = 'none'
|
||||
and (select setting from pg_settings where name = 'pgaudit.log_parameter') = 'off',
|
||||
'QUE-04: weder Anweisungen mit Parametern (log_statement, log_min_duration_statement, pgaudit) noch langsame Anweisungen werden protokolliert');
|
||||
select ok((select max((regexp_match(c, '^statement_timeout=(\d+)s$'))[1]::int) * 1000
|
||||
from pg_roles r, unnest(r.rolconfig) c where r.rolname in ('anon', 'authenticated') and c like 'statement_timeout=%')
|
||||
< (select setting::int from pg_settings where name = 'auto_explain.log_min_duration'),
|
||||
'QUE-04: auto_explain (mit Parametern) greift erst nach dem längsten Zeitlimit der API-Rollen, eine abgebrochene Suche erscheint also nie im Protokoll');
|
||||
|
||||
-- Drift simulieren: Die Funktion wird geändert, die gespeicherten Spalten bleiben (wie nach einem Image-Upgrade)
|
||||
create or replace function public.search_normalize(p text) returns text language sql immutable strict parallel safe set search_path = ''
|
||||
as $$ select upper(p) $$;
|
||||
select results_eq($$select 'games' as tabelle, count(*) > 0 as abweichend from public.games
|
||||
where title_search is distinct from public.search_normalize(title)
|
||||
union all select 'game_alt_titles', count(*) > 0 from public.game_alt_titles
|
||||
where title_search is distinct from public.search_normalize(title)
|
||||
union all select 'publishers', count(*) > 0 from public.publishers
|
||||
where name_search is distinct from public.search_normalize(name)$$,
|
||||
$$values ('games'::text, true), ('game_alt_titles', true), ('publishers', true)$$,
|
||||
'Runbook 8: die Prüfabfrage erkennt eine geänderte Normalisierung in allen drei Tabellen');
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
|
|
@ -45,5 +45,8 @@ from (
|
|||
select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean
|
||||
from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id)
|
||||
+ (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x
|
||||
union all
|
||||
select 7, 'Info-Alt', 'Startbestandsspiele mit alternativen Titeln', '-', n, null::boolean
|
||||
from (select count(distinct a.game_id) as n from public.game_alt_titles a join sb on sb.id = a.game_id) x
|
||||
) r
|
||||
order by ord;
|
||||
|
|
|
|||
|
|
@ -98,3 +98,18 @@ with want(game_id, term) as (values
|
|||
insert into public.game_publishers (game_id, publisher_id)
|
||||
select game_id, publisher_id from resolved
|
||||
on conflict (game_id, publisher_id) do nothing;
|
||||
|
||||
-- Alternative Titel: fehlende anlegen, geänderte Schreibweise aktualisieren, nicht mehr gewünschte löschen
|
||||
with want(game_id, locale, title) as (values
|
||||
('6577717f-4c1f-5829-af22-ae6bb37c8394'::uuid, 'de', 'Testspiel Bravo'),
|
||||
('6577717f-4c1f-5829-af22-ae6bb37c8394'::uuid, 'en', 'Test Game Bravo')
|
||||
), removed as (
|
||||
delete from public.game_alt_titles a
|
||||
where a.game_id = any(array['5189ad0e-ab5b-5500-a3e0-a0b3671cdf14', '6577717f-4c1f-5829-af22-ae6bb37c8394', 'c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3']::uuid[])
|
||||
and not exists (select 1 from want w where w.game_id = a.game_id and w.locale = a.locale
|
||||
and public.search_normalize(w.title) = a.title_search)
|
||||
)
|
||||
insert into public.game_alt_titles (game_id, locale, title)
|
||||
select game_id, locale, title from want
|
||||
on conflict (game_id, locale, title_search) do update set title = excluded.title
|
||||
where public.game_alt_titles.title is distinct from excluded.title;
|
||||
|
|
|
|||
Loading…
Reference in a new issue