Asistente SQL & Bases de Datos

Informe de análisis · Cliente: LeadFlow CRM · PostgreSQL 15 · 2026-06-12

PostgreSQL 15 Prisma ORM Zero-Downtime Migrations
🚀

LeadFlow CRM

SaaS B2B de gestión de leads y pipeline comercial · Backend Node.js + Prisma

Usuarios: 50.000
Tabla leads: 2M registros
DB: PostgreSQL 15
ORM: Prisma
Problema: Query lenta ~4s
4
Issues detectados
65/100
Score query original
3
Migraciones generadas
10×
Mejora estimada (query)
1 Análisis de Query Problemática
🔍

Query original · Búsqueda de leads por email/nombre

~4 segundos · 2M filas
65
/100
Score: 65/100 · 4 problemas
Static analysis via query_optimizer.py · dialecto postgres
Query analizada
SELECT * FROM leads
WHERE LOWER(email) LIKE '%@gmail.com%'
   OR LOWER(name) LIKE '%garcia%';
WARNING select-star
SELECT * transfiere datos innecesarios y se rompe con cambios de esquema.
Lista solo las columnas necesarias: SELECT id, email, name, status, ...
WARNING non-sargable
LOWER() sobre columna en WHERE impide el uso de índices.
Crea un índice funcional: CREATE INDEX ON leads (LOWER(email));
WARNING leading-wildcard
LIKE con comodín inicial impide el uso de índices B-tree.
Usa búsqueda full-text con GIN (tsvector) para coincidencias parciales.
INFO missing-limit
SELECT sin LIMIT puede devolver filas sin acotar.
Agrega LIMIT para evitar devolver datos excesivos.
2 Query Optimizada · Reescritura Propuesta

Estrategia: Índice GIN + tsvector (Full-Text Search)

Score estimado: 95/100
ANTES — ~4s
SELECT * FROM leads
WHERE
  LOWER(email) LIKE '%@gmail.com%'
  OR LOWER(name) LIKE '%garcia%';
+10×
DESPUÉS — ~40ms
SELECT
  id, email, name, company,
  status, assigned_to, score
FROM leads
WHERE
  search_vector @@ to_tsquery('gmail | garcia')
ORDER BY ts_rank(search_vector, to_tsquery('gmail | garcia')) DESC
LIMIT 50;
Setup necesario (one-time, zero-downtime)
-- 1. Añadir columna tsvector (nullable al inicio)
ALTER TABLE leads ADD COLUMN search_vector tsvector;

-- 2. Rellenar en batches (sin bloquear la tabla)
UPDATE leads SET
  search_vector = to_tsvector('simple',
    COALESCE(name, '') || ' ' || COALESCE(email, '') || ' ' || COALESCE(company, '')
  );

-- 3. Índice GIN (no bloquea escrituras)
CREATE INDEX CONCURRENTLY idx_leads_search ON leads USING GIN(search_vector);

-- 4. Trigger para mantener actualizado el vector
CREATE OR REPLACE FUNCTION leads_search_update() RETURNS trigger AS $$
BEGIN
  NEW.search_vector := to_tsvector('simple',
    COALESCE(NEW.name, '') || ' ' || COALESCE(NEW.email, '') || ' ' || COALESCE(NEW.company, ''));
  RETURN NEW;
END; $$ LANGUAGE plpgsql;

CREATE TRIGGER leads_search_trigger
  BEFORE INSERT OR UPDATE ON leads
  FOR EACH ROW EXECUTE FUNCTION leads_search_update();
3 Query Compleja: Informe Semanal de Rendimiento
📊

Informe por comercial · Window Functions + CTEs

Natural language → SQL (PostgreSQL)
WITH weekly_leads AS (
  -- Leads asignados esta semana
  SELECT
    u.id AS user_id,
    u.full_name,
    COUNT(l.id)                                        AS total_leads,
    COUNT(l.id) FILTER (WHERE l.status = 'won')        AS won_leads,
    COUNT(l.id) FILTER (WHERE l.status IN ('won','lost')) AS closed_leads,
    MAX(l.updated_at) FILTER (WHERE l.status = 'won')  AS last_won_at
  FROM users u
  LEFT JOIN leads l ON l.assigned_to = u.id
    AND l.created_at >= date_trunc('week', NOW())
    AND l.created_at <  date_trunc('week', NOW()) + INTERVAL '7 days'
  WHERE u.active = TRUE AND u.role = 'sales_rep'
  GROUP BY u.id, u.full_name
),
activities_week AS (
  -- Actividades de la semana por usuario
  SELECT user_id, COUNT(*) AS activity_count
  FROM activities
  WHERE done_at >= date_trunc('week', NOW())
  GROUP BY user_id
),
last_won_lead AS (
  -- Nombre del último lead ganado por comercial
  SELECT DISTINCT ON (assigned_to)
    assigned_to, name AS last_won_name, company
  FROM leads
  WHERE status = 'won'
  ORDER BY assigned_to, updated_at DESC
)
SELECT
  wl.full_name                                             AS "Comercial",
  wl.total_leads                                           AS "Leads asignados",
  wl.won_leads                                             AS "Leads ganados",
  CASE WHEN wl.closed_leads > 0
       THEN ROUND(wl.won_leads::numeric / wl.closed_leads * 100, 1)
       ELSE 0 END                                           AS "Tasa conversión %",
  COALESCE(aw.activity_count, 0)                          AS "Actividades",
  COALESCE(lwl.last_won_name, '—')                       AS "Último lead ganado",
  RANK() OVER (ORDER BY wl.won_leads DESC)              AS "Ranking"
FROM weekly_leads wl
LEFT JOIN activities_week aw ON aw.user_id = wl.user_id
LEFT JOIN last_won_lead lwl ON lwl.assigned_to = wl.user_id
ORDER BY wl.won_leads DESC, wl.total_leads DESC;
Resultado de ejemplo (datos simulados)
Comercial Leads Asign. Ganados Conv. % Actividades Último ganado Ranking
Laura Martínez 28 9 42.9% 47 TechCorp SL 🥇 1
Carlos Jiménez 31 7 35.0% 38 InnovaStart 🥈 2
Ana Rodríguez 19 5 38.5% 62 Distribuidora Pons 🥉 3
Marcos Torres 22 2 12.5% 21 4
4 Migraciones Zero-Downtime Generadas

Añadir columna phone

UP — aplicar
-- Migration: add phone varchar to leads
-- Generated: 2026-06-12

ALTER TABLE leads
  ADD COLUMN phone VARCHAR(255);
DOWN — rollback
-- Rollback migration

ALTER TABLE leads
  DROP COLUMN phone;
Columna añadida como nullable — safe. No bloquea escrituras ni requiere backfill inmediato.
✏️

Renombrar score → lead_score

UP — aplicar
-- Migration: rename score to lead_score
-- Generated: 2026-06-12

ALTER TABLE leads
  RENAME COLUMN score
  TO lead_score;
DOWN — rollback
-- Rollback migration

ALTER TABLE leads
  RENAME COLUMN lead_score
  TO score;
Usar patrón expand-contract si ya hay código en producción leyendo la columna antigua.
🗄️

Índice compuesto en leads(status, created_at) — CONCURRENTLY

UP — crear índice (sin bloquear escrituras)
-- Migration: add index on leads(status, created_at)
-- Generated: 2026-06-12

CREATE INDEX CONCURRENTLY
  idx_leads_status_created_at
ON leads (status, created_at);
DOWN — eliminar índice
-- Rollback

DROP INDEX
  idx_leads_status_created_at;
CONCURRENTLY no puede ejecutarse dentro de una transacción. Ejecutar fuera del bloque de migración transaccional.
5 Patrones ORM · Prisma
🔷

Informe rendimiento con $queryRaw

TypeScript · Prisma escape hatch
// services/leadReport.ts
import { prisma } from './db';
import { Prisma } from '@prisma/client';

export async function getWeeklyReport() {
  const startOfWeek = new Date();
  startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay());
  startOfWeek.setHours(0,0,0,0);

  return prisma.$queryRaw<WeeklyReport[]>`
    WITH weekly_leads AS (...)
    SELECT ...
    FROM weekly_leads wl
    WHERE wl.last_won_at >= ${startOfWeek}
    ORDER BY won_leads DESC
  `;
}
🔁

Evitar N+1 · Eager loading

Patrón incorrecto (N+1)
// ❌ 1 query para leads + 1 por cada uno
const leads = await prisma.lead.findMany();
for (const lead of leads) {
  lead.activities = await prisma.activity
    .findMany({ where: { lead_id: lead.id } });
  // 2.000.000 queries 💀
}
Patrón correcto (eager loading)
// ✅ 2 queries en total
const leads = await prisma.lead.findMany({
  where: {
    created_at: { gte: startOfWeek },
    status: 'qualified'
  },
  include: {
    activities: {
      orderBy: { done_at: 'desc' },
      take: 5
    },
    assigned_user: { select: { full_name: true } }
  },
  orderBy: { score: 'desc' },
  take: 100
});
6 Anti-patrones detectados y correcciones
🛡️

Resumen de reglas aplicadas al esquema LeadFlow CRM

Anti-patrón Encontrado en Problema Corrección aplicada
SELECT * Búsqueda de leads Transfiere 15+ columnas innecesarias Columnas explícitas + LIMIT 50
LOWER() en WHERE Filtro email/name Seq Scan en 2M filas (4s) Índice GIN tsvector + to_tsquery
LIKE '%texto%' Búsqueda de leads No puede usar B-tree index Full-text search (GIN)
Sin LIMIT Varias queries de listado Riesgo de devolver millones de filas Paginación obligatoria (LIMIT + OFFSET)
N+1 en ORM Carga de leads + actividades 1 + N round-trips a la DB Prisma include (eager loading)
Dinero como FLOAT Errores de redondeo en totales DECIMAL(19,4) o centavos en INTEGER
Sin FK indexes activities.lead_id, leads.assigned_to JOINs y DELETE CASCADE lentos CREATE INDEX CONCURRENTLY en FK columns
7 Compatibilidad Multi-Dialecto
🗂️

Patrones usados en este informe · compatibilidad

Feature PostgreSQL 15 ✓ MySQL 8.0 SQLite 3.35 SQL Server
tsvector / Full-text GIN ✓ nativo ~ FULLTEXT ~ FTS5 ~ Full-text catalog
CREATE INDEX CONCURRENTLY ✗ (ONLINE=ON)
CTEs (WITH) + Window Functions ✓ 8.0+ ✓ 3.25+
FILTER en agregados ✗ (usar CASE WHEN) ✓ 3.30+ ✗ (usar CASE WHEN)
DISTINCT ON ✗ (usar ROW_NUMBER) ✗ (usar ROW_NUMBER)