Shuffle/supabase/migrations/20261103235947_catalog_search.sql
cschlaefke 9fa91eb684
All checks were successful
CI / flutter (pull_request) Successful in 1m19s
CI / web-build (pull_request) Successful in 57s
CI / supabase (pull_request) Successful in 11s
CI / supabase-tests (pull_request) Successful in 38s
CI / e2e (pull_request) Successful in 2m59s
fix(db): single filter set, filter tests with query, perf guards, evidence docs (KAT-01, 3a review)
Filters are evaluated once into an id set used by both search branches.
Adds tests for age/genre/mechanic filters with a search term, rank 2 vs 3
and null p_limit. approxNormalize uses NFKD. Perf script checks confirm_local
value and unix socket and re-analyzes after rollback. Adds
docs/specs/3-katalog-suche-umsetzung.md (measurements, log check incl. GET
finding), release checklist section 2a and the CSV-only note for alt titles.

Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 10:39:02 +01:00

341 lines
17 KiB
PL/PgSQL

-- 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;