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ónSDKFunción SQL
CRUD simpleNo necesario
Filtros y paginaciónNo necesario
Búsqueda full-textLimitadoRecomendado
Agregaciones (SUM, AVG, COUNT con GROUP BY)NoNecesario
Operaciones que tocan múltiples tablasMúltiples queriesUn solo query
Lógica de negocio complejaDifícilRecomendado
Operaciones en batchPosible pero lentoMás eficiente
Bypasear RLS intencionalmenteNoCon SECURITY DEFINER

Crear tu primera función

Las funciones se crean con CREATE FUNCTION en el SQL Editor de Supabase.

Estructura básica

sql
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

sql
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:

typescript
const { data: ofertas, error } = await supabase.rpc('obtener_ofertas')
 
// data: Producto[] -- los mismos campos que la tabla productos

Función con parámetros

sql
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;
$$;
typescript
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.

sql
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.

sql
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;
$$;
typescript
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.

sql
-- 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'.

typescript
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.

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

sql
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;
$$;
typescript
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.

sql
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;
$$;
typescript
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.

sql
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.

sql
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

sql
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

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

sql
-- 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:

  1. Necesitas una función? Si el SDK puede hacer el query, no crees una función
  2. Nombre descriptivo en snake_case: buscar_productos, crear_pedido_completo
  3. Parámetros tipados con nombres claros
  4. Retorno explícito con RETURNS (tipo simple, TABLE o SETOF)
  5. Lenguaje correcto: sql para queries simples, plpgsql para lógica
  6. Seguridad: INVOKER por defecto, DEFINER solo si necesitas bypasear RLS
  7. Manejo de errores con RAISE EXCEPTION en plpgsql
  8. 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.