From 220b830aa87c1b1cfa99fed4220e7f266a1b2006 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Tue, 24 Mar 2026 20:46:35 -0600 Subject: [PATCH 1/2] =?UTF-8?q?Full=20text=20search=20sobre=20asignaturas?= =?UTF-8?q?=20para=20clonaci=C3=B3n=20de=20asignaturas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...606_full_text_search_sobre_asignaturas.sql | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 supabase/migrations/20260325024606_full_text_search_sobre_asignaturas.sql diff --git a/supabase/migrations/20260325024606_full_text_search_sobre_asignaturas.sql b/supabase/migrations/20260325024606_full_text_search_sobre_asignaturas.sql new file mode 100644 index 0000000..12638b9 --- /dev/null +++ b/supabase/migrations/20260325024606_full_text_search_sobre_asignaturas.sql @@ -0,0 +1,167 @@ +-- Paso 1 +drop text search configuration if exists public.es_simple_unaccent cascade; + +create text search configuration public.es_simple_unaccent (copy = pg_catalog.simple); + +alter text search configuration public.es_simple_unaccent + alter mapping for hword, hword_part, word + with unaccent, simple; + +-- Paso 2 +alter table public.asignaturas +add column if not exists search_vector tsvector; + +-- Paso 3 +create or replace function public.fn_asignaturas_update_search_vector() +returns trigger +language plpgsql +as $$ +begin + new.search_vector := + setweight( + to_tsvector('public.es_simple_unaccent', coalesce(new.nombre, '')), + 'A' + ) + || + setweight( + to_tsvector('public.es_simple_unaccent', coalesce(new.codigo, '')), + 'A' + ) + || + setweight( + to_tsvector('public.es_simple_unaccent', coalesce(new.datos, '{}'::jsonb)::text), + 'B' + ) + || + setweight( + to_tsvector('public.es_simple_unaccent', coalesce(new.contenido_tematico, '[]'::jsonb)::text), + 'B' + ); + + return new; +end; +$$; + +-- Paso 4 +drop trigger if exists trg_asignaturas_search_vector on public.asignaturas; + +create trigger trg_asignaturas_search_vector +before insert or update of nombre, codigo, datos, contenido_tematico +on public.asignaturas +for each row +execute function public.fn_asignaturas_update_search_vector(); + +-- Paso 5 +update public.asignaturas +set search_vector = + setweight( + to_tsvector('public.es_simple_unaccent', coalesce(nombre, '')), + 'A' + ) + || + setweight( + to_tsvector('public.es_simple_unaccent', coalesce(codigo, '')), + 'A' + ) + || + setweight( + to_tsvector('public.es_simple_unaccent', coalesce(datos, '{}'::jsonb)::text), + 'B' + ) + || + setweight( + to_tsvector('public.es_simple_unaccent', coalesce(contenido_tematico, '[]'::jsonb)::text), + 'B' + ); + +-- Paso 6 +create index if not exists asignaturas_search_vector_gin_idx +on public.asignaturas +using gin (search_vector); + +-- Paso 7 +create or replace function public.build_asignaturas_prefix_tsquery(p_search text) +returns tsquery +language plpgsql +stable +as $$ +declare + cleaned text; + tokens text[]; + query_text text; +begin + cleaned := trim(coalesce(p_search, '')); + + if cleaned = '' then + return null; + end if; + + cleaned := lower(public.unaccent(cleaned)); + cleaned := regexp_replace(cleaned, '[^[:alnum:]\s]+', ' ', 'g'); + cleaned := regexp_replace(cleaned, '\s+', ' ', 'g'); + + tokens := regexp_split_to_array(cleaned, '\s+'); + + select string_agg(token || ':*', ' & ') + into query_text + from unnest(tokens) as token + where token <> ''; + + if query_text is null or query_text = '' then + return null; + end if; + + return to_tsquery('public.es_simple_unaccent', query_text); +end; +$$; + +-- Paso 8 +create or replace function public.search_asignaturas( + p_search text, + p_plan_estudio_id uuid default null, + p_limit integer default 20, + p_offset integer default 0 +) +returns table ( + id uuid, + plan_estudio_id uuid, + codigo text, + nombre text, + tipo public.tipo_asignatura, + creditos numeric, + numero_ciclo integer, + datos jsonb, + contenido_tematico jsonb, + estado public.estado_asignatura, + rank real +) +language sql +stable +as $$ + with q as ( + select public.build_asignaturas_prefix_tsquery(p_search) as tsq + ) + select + a.id, + a.plan_estudio_id, + a.codigo, + a.nombre, + a.tipo, + a.creditos, + a.numero_ciclo, + a.datos, + a.contenido_tematico, + a.estado, + ts_rank(a.search_vector, q.tsq) as rank + from public.asignaturas a + cross join q + where + q.tsq is not null + and a.search_vector @@ q.tsq + and (p_plan_estudio_id is null or a.plan_estudio_id = p_plan_estudio_id) + order by + rank desc, + a.nombre asc + limit p_limit + offset p_offset; +$$; \ No newline at end of file From 542f2bc61a21d8b033f37997c896b98bb36030ad Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Wed, 25 Mar 2026 16:44:11 -0600 Subject: [PATCH 2/2] migraciones para soportar el full text search --- supabase/.temp/cli-latest | 2 +- supabase/functions/_shared/database.types.ts | 66 +++++++++++++-- ...606_full_text_search_sobre_asignaturas.sql | 82 +++++++++++-------- 3 files changed, 111 insertions(+), 39 deletions(-) diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest index 0cce2dc..47c148f 100644 --- a/supabase/.temp/cli-latest +++ b/supabase/.temp/cli-latest @@ -1 +1 @@ -v2.78.1 \ No newline at end of file +v2.84.2 \ No newline at end of file diff --git a/supabase/functions/_shared/database.types.ts b/supabase/functions/_shared/database.types.ts index 35f2364..7f378ed 100644 --- a/supabase/functions/_shared/database.types.ts +++ b/supabase/functions/_shared/database.types.ts @@ -1,4 +1,4 @@ -export type Json = +export type Json = | string | number | boolean @@ -7,10 +7,30 @@ export type Json = | Json[] export type Database = { - // Allows to automatically instantiate createClient with right options - // instead of createClient(URL, KEY) - __InternalSupabase: { - PostgrestVersion: "14.1" + graphql_public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + graphql: { + Args: { + extensions?: Json + operationName?: string + query?: string + variables?: Json + } + Returns: Json + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } } public: { Tables: { @@ -135,6 +155,7 @@ export type Database = { orden_celda: number | null plan_estudio_id: string prerrequisito_asignatura_id: string | null + search_vector: unknown tipo: Database["public"]["Enums"]["tipo_asignatura"] tipo_origen: Database["public"]["Enums"]["tipo_origen"] | null } @@ -161,6 +182,7 @@ export type Database = { orden_celda?: number | null plan_estudio_id: string prerrequisito_asignatura_id?: string | null + search_vector?: unknown tipo?: Database["public"]["Enums"]["tipo_asignatura"] tipo_origen?: Database["public"]["Enums"]["tipo_origen"] | null } @@ -187,6 +209,7 @@ export type Database = { orden_celda?: number | null plan_estudio_id?: string prerrequisito_asignatura_id?: string | null + search_vector?: unknown tipo?: Database["public"]["Enums"]["tipo_asignatura"] tipo_origen?: Database["public"]["Enums"]["tipo_origen"] | null } @@ -1373,6 +1396,35 @@ export type Database = { Args: { p_append: Json; p_id: string } Returns: undefined } + build_asignaturas_prefix_tsquery: { + Args: { p_search: string } + Returns: unknown + } + recalcular_vectores_asignaturas: { Args: never; Returns: undefined } + search_asignaturas: { + Args: { + p_carrera_id?: string + p_facultad_id?: string + p_limit?: number + p_offset?: number + p_plan_estudio_id?: string + p_search?: string + } + Returns: { + codigo: string + contenido_tematico: Json + creditos: number + datos: Json + estado: Database["public"]["Enums"]["estado_asignatura"] + id: string + nombre: string + numero_ciclo: number + plan_estudio_id: string + rank: number + tipo: Database["public"]["Enums"]["tipo_asignatura"] + total_count: number + }[] + } suma_porcentajes: { Args: { "": Json }; Returns: number } unaccent: { Args: { "": string }; Returns: string } unaccent_immutable: { Args: { "": string }; Returns: string } @@ -1548,6 +1600,9 @@ export type CompositeTypes< : never export const Constants = { + graphql_public: { + Enums: {}, + }, public: { Enums: { estado_asignatura: ["borrador", "revisada", "aprobada", "generando"], @@ -1607,3 +1662,4 @@ export const Constants = { }, }, } as const + diff --git a/supabase/migrations/20260325024606_full_text_search_sobre_asignaturas.sql b/supabase/migrations/20260325024606_full_text_search_sobre_asignaturas.sql index 12638b9..9c737f6 100644 --- a/supabase/migrations/20260325024606_full_text_search_sobre_asignaturas.sql +++ b/supabase/migrations/20260325024606_full_text_search_sobre_asignaturas.sql @@ -52,27 +52,18 @@ for each row execute function public.fn_asignaturas_update_search_vector(); -- Paso 5 -update public.asignaturas -set search_vector = - setweight( - to_tsvector('public.es_simple_unaccent', coalesce(nombre, '')), - 'A' - ) - || - setweight( - to_tsvector('public.es_simple_unaccent', coalesce(codigo, '')), - 'A' - ) - || - setweight( - to_tsvector('public.es_simple_unaccent', coalesce(datos, '{}'::jsonb)::text), - 'B' - ) - || - setweight( - to_tsvector('public.es_simple_unaccent', coalesce(contenido_tematico, '[]'::jsonb)::text), - 'B' - ); +create or replace function public.recalcular_vectores_asignaturas() +returns void +language sql +as $$ + UPDATE public.asignaturas + SET search_vector = + setweight(to_tsvector('public.es_simple_unaccent', coalesce(nombre, '')), 'A') || + setweight(to_tsvector('public.es_simple_unaccent', coalesce(codigo, '')), 'A') || + setweight(to_tsvector('public.es_simple_unaccent', coalesce(datos, '{}'::jsonb)::text), 'B') || + setweight(to_tsvector('public.es_simple_unaccent', coalesce(contenido_tematico, '[]'::jsonb)::text), 'B') + WHERE id IS NOT NULL; +$$; -- Paso 6 create index if not exists asignaturas_search_vector_gin_idx @@ -117,7 +108,9 @@ $$; -- Paso 8 create or replace function public.search_asignaturas( - p_search text, + p_search text default '', + p_facultad_id uuid default null, + p_carrera_id uuid default null, p_plan_estudio_id uuid default null, p_limit integer default 20, p_offset integer default 0 @@ -133,14 +126,19 @@ returns table ( datos jsonb, contenido_tematico jsonb, estado public.estado_asignatura, - rank real + rank real, + total_count bigint -- 👈 Total para la paginación del frontend ) -language sql +language plpgsql stable as $$ - with q as ( - select public.build_asignaturas_prefix_tsquery(p_search) as tsq - ) +declare + v_tsq tsquery; +begin + -- 1. Construimos el query solo si hay texto + v_tsq := public.build_asignaturas_prefix_tsquery(p_search); + + return query select a.id, a.plan_estudio_id, @@ -152,16 +150,34 @@ as $$ a.datos, a.contenido_tematico, a.estado, - ts_rank(a.search_vector, q.tsq) as rank + coalesce(ts_rank(a.search_vector, v_tsq), 0)::real as rank, + count(*) OVER() as total_count -- 👈 Cuenta total ignorando el LIMIT from public.asignaturas a - cross join q + -- 2. JOINS para poder filtrar por la jerarquía superior + left join public.planes_estudio p on a.plan_estudio_id = p.id + left join public.carreras c on p.carrera_id = c.id where - q.tsq is not null - and a.search_vector @@ q.tsq + -- 3. Si no hay búsqueda, trae todo. Si hay búsqueda, usa el FTS + (v_tsq is null or a.search_vector @@ v_tsq) + -- 4. Filtros jerárquicos dinámicos and (p_plan_estudio_id is null or a.plan_estudio_id = p_plan_estudio_id) + and (p_carrera_id is null or p.carrera_id = p_carrera_id) + and (p_facultad_id is null or c.facultad_id = p_facultad_id) order by - rank desc, + (case when v_tsq is not null then ts_rank(a.search_vector, v_tsq) else 0 end) desc, a.nombre asc limit p_limit offset p_offset; -$$; \ No newline at end of file +end; +$$; + +-- Corrección de bug con triggers +-- 1. Borramos la llave foránea estricta actual +ALTER TABLE public.cambios_asignatura +DROP CONSTRAINT cambios_asignatura_asignatura_id_fkey; + +-- 2. La volvemos a crear, pero le decimos que espere al final de la transacción para validar +ALTER TABLE public.cambios_asignatura +ADD CONSTRAINT cambios_asignatura_asignatura_id_fkey +FOREIGN KEY (asignatura_id) REFERENCES public.asignaturas(id) +DEFERRABLE INITIALLY DEFERRED; \ No newline at end of file