Shuffle/supabase/migrations/20261103235947_catalog_search.sql
cschlaefke 3bb1c28831 feat(db): catalog search with search_normalize, alt titles and search_catalog_games RPC (KAT-01, 3a)
Adds pg_trgm/unaccent, search_normalize, generated title_search/name_search
columns, game_alt_titles (read-only for API roles), manual_fields key
alt_titles and the RPC search_catalog_games. pgTAP 09_catalog_search covers
rights, normalization, relevance, filters, paging and the runbook snippets.

Refs #3

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

314 lines
16 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[] := '{}';
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;