Funciones SQL y RPC en Supabase
Las funciones SQL (también llamadas stored procedures o funciones almacenadas) son bloques de lógica que viven dentro de PostgreSQL. Las escribes una vez, se guardan en la base de datos, y las puedes llamar desde el SDK usando RPC (Remote Procedure Call -- llamada a procedimiento remoto).
Son la herramienta que necesitas cuando la API del SDK no alcanza: búsquedas complejas, agregaciones, operaciones en batch, lógica que involucra múltiples tablas, o cualquier cosa que necesite SQL puro.
Cuándo usar funciones vs el SDK
El SDK cubre el 80% de los casos con .select(), .insert(), .update() y .delete(). Pero hay situaciones donde escribir una función SQL es la opción correcta:
| Situación | SDK | Función SQL |
|---|---|---|
| CRUD simple | Sí | No necesario |
| Filtros y paginación | Sí | No necesario |
| Búsqueda full-text | Limitado | Recomendado |
| Agregaciones (SUM, AVG, COUNT con GROUP BY) | No | Necesario |
| Operaciones que tocan múltiples tablas | Múltiples queries | Un solo query |
| Lógica de negocio compleja | Difícil | Recomendado |
| Operaciones en batch | Posible pero lento | Más eficiente |
| Bypasear RLS intencionalmente | No | Con SECURITY DEFINER |
Crear tu primera función
Las funciones se crean con CREATE FUNCTION en el SQL Editor de Supabase.
Estructura básica
CREATE OR REPLACE FUNCTION nombre_de_funcion(parametro1 tipo, parametro2 tipo)
RETURNS tipo_de_retorno
LANGUAGE sql -- o plpgsql
AS $$
-- Tu SQL aqui
$$;CREATE OR REPLACE crea la función si no existe, o la reemplaza si ya existe. Es más seguro que solo CREATE FUNCTION porque no falla si la función ya fue creada.
Ejemplo simple: productos en oferta
CREATE OR REPLACE FUNCTION obtener_ofertas()
RETURNS SETOF productos
LANGUAGE sql
AS $$
SELECT *
FROM productos
WHERE precio_descuento IS NOT NULL
AND precio_descuento < precio
AND disponible = true
ORDER BY (precio - precio_descuento) DESC;
$$;RETURNS SETOF productos significa que la función devuelve múltiples filas con la estructura de la tabla productos.
Llamarla desde el SDK:
const { data: ofertas, error } = await supabase.rpc('obtener_ofertas')
// data: Producto[] -- los mismos campos que la tabla productosFunción con parámetros
CREATE OR REPLACE FUNCTION obtener_productos_por_rango_precio(
precio_min numeric,
precio_max numeric
)
RETURNS SETOF productos
LANGUAGE sql
AS $$
SELECT *
FROM productos
WHERE precio >= precio_min
AND precio <= precio_max
AND disponible = true
ORDER BY precio ASC;
$$;const { data, error } = await supabase.rpc('obtener_productos_por_rango_precio', {
precio_min: 1000,
precio_max: 5000
})Los nombres de los parámetros en el SDK deben coincidir exactamente con los nombres definidos en la función SQL.
LANGUAGE sql vs plpgsql
PostgreSQL soporta varios lenguajes para funciones. Los dos más comunes son sql y plpgsql.
LANGUAGE sql
Para funciones que son un solo query SQL. Más simple y ligeramente más eficiente.
CREATE OR REPLACE FUNCTION contar_productos_por_categoria(cat text)
RETURNS bigint
LANGUAGE sql
AS $$
SELECT count(*)
FROM productos
WHERE categoria = cat AND disponible = true;
$$;LANGUAGE plpgsql
Para funciones que necesitan lógica: variables, condicionales, loops, manejo de errores.
CREATE OR REPLACE FUNCTION procesar_pedido(
pedido_id uuid,
nuevo_estado text
)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
pedido_actual record;
resultado jsonb;
BEGIN
-- Obtener el pedido actual
SELECT * INTO pedido_actual
FROM pedidos
WHERE id = pedido_id;
-- Verificar que el pedido existe
IF NOT FOUND THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Pedido no encontrado'
);
END IF;
-- Validar la transicion de estado
IF pedido_actual.estado = 'cancelado' THEN
RETURN jsonb_build_object(
'success', false,
'error', 'No se puede modificar un pedido cancelado'
);
END IF;
-- Actualizar el estado
UPDATE pedidos
SET estado = nuevo_estado, updated_at = now()
WHERE id = pedido_id;
-- Si se cancela, devolver el stock
IF nuevo_estado = 'cancelado' THEN
UPDATE productos p
SET stock = p.stock + di.cantidad
FROM detalle_pedidos di
WHERE di.pedido_id = pedido_id
AND p.id = di.producto_id;
END IF;
RETURN jsonb_build_object(
'success', true,
'pedido_id', pedido_id,
'estado_anterior', pedido_actual.estado,
'estado_nuevo', nuevo_estado
);
END;
$$;const { data, error } = await supabase.rpc('procesar_pedido', {
pedido_id: '550e8400-e29b-41d4-a716-446655440000',
nuevo_estado: 'enviado'
})
// data: { success: true, pedido_id: "...", estado_anterior: "pendiente", estado_nuevo: "enviado" }Regla práctica
Si tu función es un solo SELECT, INSERT, UPDATE o DELETE, usa LANGUAGE sql. Si necesitas variables, IF/ELSE, loops o manejo de errores, usa LANGUAGE plpgsql.
Ejemplo práctico: búsqueda full-text
Una de las funciones más útiles que puedes crear es una búsqueda de texto completo. PostgreSQL tiene soporte nativo para esto con tsvector y tsquery.
-- Primero, agrega una columna de búsqueda a tu tabla
ALTER TABLE productos
ADD COLUMN busqueda tsvector
GENERATED ALWAYS AS (
to_tsvector('spanish', coalesce(nombre, '') || ' ' || coalesce(descripcion, '') || ' ' || coalesce(categoria, ''))
) STORED;
-- Crea un indice GIN para que las busquedas sean rapidas
CREATE INDEX idx_productos_busqueda ON productos USING gin(busqueda);
-- Función de búsqueda
CREATE OR REPLACE FUNCTION buscar_productos(termino text)
RETURNS TABLE (
id uuid,
nombre text,
precio numeric,
categoria text,
relevancia real
)
LANGUAGE sql
AS $$
SELECT
p.id,
p.nombre,
p.precio,
p.categoria,
ts_rank(p.busqueda, websearch_to_tsquery('spanish', termino)) AS relevancia
FROM productos p
WHERE p.busqueda @@ websearch_to_tsquery('spanish', termino)
AND p.disponible = true
ORDER BY relevancia DESC
LIMIT 20;
$$;websearch_to_tsquery convierte texto normal en una query de búsqueda. Soporta sintaxis tipo Google: "monitor 4k" busca ambas palabras, y maneja plurales y variaciones automáticamente en español gracias al parámetro 'spanish'.
const { data: resultados, error } = await supabase.rpc('buscar_productos', {
termino: 'monitor 4k'
})
// data: [
// { id: "...", nombre: "Monitor 4K 27\"", precio: 8500, categoria: "monitores", relevancia: 0.85 },
// { id: "...", nombre: "Monitor 4K 32\"", precio: 12000, categoria: "monitores", relevancia: 0.72 }
// ]Ejemplo práctico: agregaciones
El SDK no soporta funciones de agregación como SUM, AVG o GROUP BY directamente. Para eso necesitas una función SQL.
CREATE OR REPLACE FUNCTION estadisticas_por_categoria()
RETURNS TABLE (
categoria text,
total_productos bigint,
precio_promedio numeric,
precio_minimo numeric,
precio_maximo numeric,
stock_total bigint
)
LANGUAGE sql
AS $$
SELECT
categoria,
count(*) AS total_productos,
round(avg(precio), 2) AS precio_promedio,
min(precio) AS precio_minimo,
max(precio) AS precio_maximo,
sum(stock)::bigint AS stock_total
FROM productos
WHERE disponible = true
GROUP BY categoria
ORDER BY total_productos DESC;
$$;const { data: stats, error } = await supabase.rpc('estadisticas_por_categoria')
// data: [
// {
// categoria: "electronica",
// total_productos: 45,
// precio_promedio: 3500.00,
// precio_minimo: 150.00,
// precio_maximo: 25000.00,
// stock_total: 1250
// },
// ...
// ]Estadísticas con periodo de tiempo
CREATE OR REPLACE FUNCTION ventas_por_periodo(
fecha_inicio timestamptz,
fecha_fin timestamptz
)
RETURNS TABLE (
fecha date,
total_pedidos bigint,
ingresos numeric
)
LANGUAGE sql
AS $$
SELECT
date_trunc('day', created_at)::date AS fecha,
count(*) AS total_pedidos,
sum(total) AS ingresos
FROM pedidos
WHERE created_at >= fecha_inicio
AND created_at <= fecha_fin
AND estado != 'cancelado'
GROUP BY fecha
ORDER BY fecha ASC;
$$;const { data: ventas, error } = await supabase.rpc('ventas_por_periodo', {
fecha_inicio: '2026-03-01T00:00:00Z',
fecha_fin: '2026-03-31T23:59:59Z'
})Ejemplo práctico: operaciones en batch
Cuando necesitas hacer múltiples operaciones que deben ser atómicas (todas o ninguna), una función con transacción implícita es la solución.
CREATE OR REPLACE FUNCTION crear_pedido_completo(
usuario_id uuid,
items jsonb -- [{ "producto_id": "...", "cantidad": 2 }, ...]
)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
nuevo_pedido_id uuid;
item jsonb;
producto record;
total_pedido numeric := 0;
BEGIN
-- Crear el pedido
INSERT INTO pedidos (usuario_id, estado, total)
VALUES (usuario_id, 'pendiente', 0)
RETURNING id INTO nuevo_pedido_id;
-- Procesar cada item
FOR item IN SELECT * FROM jsonb_array_elements(items)
LOOP
-- Obtener el producto y verificar stock
SELECT * INTO producto
FROM productos
WHERE id = (item->>'producto_id')::uuid
AND disponible = true;
IF NOT FOUND THEN
RAISE EXCEPTION 'Producto % no encontrado o no disponible',
item->>'producto_id';
END IF;
IF producto.stock < (item->>'cantidad')::int THEN
RAISE EXCEPTION 'Stock insuficiente para %: disponible %, solicitado %',
producto.nombre, producto.stock, item->>'cantidad';
END IF;
-- Crear el detalle del pedido
INSERT INTO detalle_pedidos (pedido_id, producto_id, cantidad, precio_unitario)
VALUES (
nuevo_pedido_id,
producto.id,
(item->>'cantidad')::int,
producto.precio
);
-- Actualizar stock
UPDATE productos
SET stock = stock - (item->>'cantidad')::int
WHERE id = producto.id;
-- Sumar al total
total_pedido := total_pedido + (producto.precio * (item->>'cantidad')::int);
END LOOP;
-- Actualizar el total del pedido
UPDATE pedidos
SET total = total_pedido
WHERE id = nuevo_pedido_id;
RETURN jsonb_build_object(
'success', true,
'pedido_id', nuevo_pedido_id,
'total', total_pedido
);
END;
$$;const { data, error } = await supabase.rpc('crear_pedido_completo', {
usuario_id: userId,
items: [
{ producto_id: 'abc-123', cantidad: 2 },
{ producto_id: 'def-456', cantidad: 1 }
]
})
if (error) {
// Si cualquier paso fallo, toda la operación se revierte
console.error('Error al crear pedido:', error.message)
} else {
console.log('Pedido creado:', data.pedido_id, 'Total:', data.total)
}Si RAISE EXCEPTION se ejecuta en cualquier punto, PostgreSQL revierte automáticamente todos los INSERT y UPDATE que se hicieron dentro de la función. Esto es la atomicidad de las transacciones.
SECURITY DEFINER vs SECURITY INVOKER
Este concepto es importante cuando usas Row Level Security (RLS).
SECURITY INVOKER (default)
La función se ejecuta con los permisos del usuario que la llama. Si el usuario tiene RLS habilitado, las políticas aplican normalmente.
CREATE OR REPLACE FUNCTION mis_productos()
RETURNS SETOF productos
LANGUAGE sql
SECURITY INVOKER -- default, no es necesario escribirlo
AS $$
SELECT * FROM productos;
-- RLS filtra automáticamente: solo ve los productos que el usuario tiene permiso de ver
$$;SECURITY DEFINER
La función se ejecuta con los permisos del usuario que la creó (generalmente el superusuario). Las políticas RLS NO aplican dentro de la función.
CREATE OR REPLACE FUNCTION obtener_estadisticas_globales()
RETURNS jsonb
LANGUAGE sql
SECURITY DEFINER
AS $$
-- Esta funcion puede ver TODOS los registros, sin importar RLS
SELECT jsonb_build_object(
'total_usuarios', (SELECT count(*) FROM auth.users),
'total_productos', (SELECT count(*) FROM productos),
'total_pedidos', (SELECT count(*) FROM pedidos)
);
$$;SECURITY DEFINER con cuidado
Una función con SECURITY DEFINER bypasea todas las políticas RLS. Usa esta opción solo cuando realmente necesites acceso a datos que el usuario no debería ver directamente (estadísticas globales, operaciones administrativas). Siempre valida los parámetros de entrada para evitar inyecciones SQL.
Buena práctica: combinar SECURITY DEFINER con validación
CREATE OR REPLACE FUNCTION admin_actualizar_estado_pedido(
pedido_id uuid,
nuevo_estado text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
usuario_actual uuid;
es_admin boolean;
BEGIN
-- Obtener el usuario actual desde el JWT de Supabase
usuario_actual := auth.uid();
-- Verificar que es admin
SELECT is_admin INTO es_admin
FROM perfiles
WHERE id = usuario_actual;
IF NOT es_admin THEN
RAISE EXCEPTION 'Solo administradores pueden usar esta función';
END IF;
-- Validar el estado
IF nuevo_estado NOT IN ('pendiente', 'procesando', 'enviado', 'entregado', 'cancelado') THEN
RAISE EXCEPTION 'Estado inválido: %', nuevo_estado;
END IF;
-- Ejecutar la actualizacion (sin restricciones de RLS)
UPDATE pedidos
SET estado = nuevo_estado, updated_at = now()
WHERE id = pedido_id;
RETURN jsonb_build_object('success', true);
END;
$$;auth.uid() es una función de Supabase que devuelve el ID del usuario autenticado desde el JWT token. Es la forma de saber quién está llamando la función.
Eliminar funciones
-- Eliminar una función
DROP FUNCTION IF EXISTS buscar_productos(text);
-- Si tiene multiples versiones (overloads), especifica los parametros
DROP FUNCTION IF EXISTS obtener_productos_por_rango_precio(numeric, numeric);Listar funciones existentes
-- Ver todas las funciones en el schema public
SELECT
routine_name,
routine_type,
data_type AS return_type
FROM information_schema.routines
WHERE routine_schema = 'public'
ORDER BY routine_name;Checklist para crear funciones
Antes de crear una función, revisa esta lista:
- Necesitas una función? Si el SDK puede hacer el query, no crees una función
- Nombre descriptivo en snake_case:
buscar_productos,crear_pedido_completo - Parámetros tipados con nombres claros
- Retorno explícito con
RETURNS(tipo simple, TABLE o SETOF) - Lenguaje correcto:
sqlpara queries simples,plpgsqlpara lógica - Seguridad:
INVOKERpor defecto,DEFINERsolo si necesitas bypasear RLS - Manejo de errores con
RAISE EXCEPTIONen plpgsql - Validación de inputs especialmente en funciones SECURITY DEFINER
Siguiente paso
Con funciones SQL y RPC tienes la capacidad de ejecutar cualquier lógica en tu base de datos. El siguiente tema es autenticación -- cómo manejar usuarios, sesiones y proteger tus datos con Row Level Security.