-- 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[] := '{}'; v_has_query boolean := false; v_genres uuid[]; v_mechanics uuid[]; v_genre_n integer; v_mech_n integer; 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), '{}'); 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); 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 (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 (select count(*) from public.game_genres gg where gg.game_id = c.id and gg.genre_id = any (v_genres)) = v_genre_n) and (v_mech_n = 0 or (select count(*) from public.game_mechanics gm where gm.game_id = c.id and gm.mechanic_id = any (v_mechanics)) = v_mech_n) 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 (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 (select count(*) from public.game_genres gg where gg.game_id = c.id and gg.genre_id = any (v_genres)) = v_genre_n) and (v_mech_n = 0 or (select count(*) from public.game_mechanics gm where gm.game_id = c.id and gm.mechanic_id = any (v_mechanics)) = v_mech_n) ), -- 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) ranked as ( select t.id, t.ts, t.alt_title, case when t.ts = v_q or 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_words) > 0 and not exists (select 1 from unnest(v_words) as w where t.ts not like '%' || w || '%')) then 3 end as rk, extensions.similarity(t.ts, v_q) as sim 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, (array_agg(r.alt_title order by r.rk, r.sim desc nulls last, r.ts, r.alt_title) filter (where r.alt_title is not null and r.rk is not null))[1] as alt_title, max(r.sim) as sim from ranked r group by r.id ), hit as ( select b.*, a.best_rk, a.main_rk, a.alt_rk, a.alt_title, a.sim from base b join agg a on a.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 not exists ( select 1 from unnest(v_words) as w where not (exists (select 1 from ranked r2 where r2.id = b.id and r2.ts like '%' || w || '%') or exists (select 1 from public.game_publishers gp join public.publishers p on p.id = gp.publisher_id where gp.game_id = b.id and p.name_search like '%' || w || '%')))) ), paged as ( select h.*, coalesce(h.best_rk, 4) as rk, count(*) over () as total from hit h order by coalesce(h.best_rk, 4), h.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;