Inicio / Artículos / ¿Lluvia fina o Prisma? Verifique el tipo de unión y la SQL registrada antes de elegir.

¿Lluvia fina o Prisma? Verifique el tipo de unión y la SQL registrada antes de elegir.

Modele las mismas tablas de usuarios y facturas en Drizzle y Prisma, compare los tipos de resultados de las uniones y el SQL registrado, y detecte las asignaciones del controlador que convierten los totales en cadenas de texto.

2461 palabras

Los debates sobre ORM suelen centrarse en gráficos de descargas y eslóganes de conferencias, pero la pregunta real en entornos de producción es mucho más sencilla: cuando relacionas a un usuario con sus facturas, ¿qué tipo tiene total y puedes leer el SQL que lo generó? Esta guía crea las mismas dos tablas en Drizzle y en Prisma, ejecuta una inserción y un join en cada uno, y compara los tipos inferidos de TypeScript, las consultas registradas, los resultados de las migraciones y el comportamiento al ejecutarse con TypeScript nativo de Node. Obtendrás un laboratorio breve y reproducible que responde a la pregunta del ORM para tu propio código, en lugar de basarte en pruebas de otros. Para un marco de toma de decisiones más amplio que también considere el SQL puro, consulta cómo elegir una capa de base de datos entre SQL puro, Prisma y Drizzle.

Para qué se optimiza cada herramienta

Las dos bibliotecas ofrecen características diferentes. Drizzle proporciona código de consultas que se parece al SQL escrito en TypeScript, sin un proceso separado de motor de consultas y con un diseño adecuado para entornos edge. Prisma ofrece un flujo de trabajo basado en esquemas, con un archivo dedicado schema.prisma y un cliente generado; su línea de lanzamientos recientes ha ido alejando su motor de consultas de Rust hacia TypeScript. Esa transición del motor aún estaba en curso en el momento de redactar este texto, por lo que consulte las notas de lanzamiento actuales de Prisma para saber qué motor utiliza su versión.

La popularidad también tiene efectos contradictorios: Prisma sigue liderando en número de instalaciones, mientras que Drizzle domina las conversaciones sobre crecimiento. Nada de esto le dice nada sobre su caso específico. Los dos criterios utilizados a continuación son deliberadamente limitados y prácticos: si total se presenta como un number, y si el SQL del registro es algo que uno estaría dispuesto a pegar en psql durante un incidente.

El escenario es una pequeña aplicación de facturación donde la página /invoices debe mostrar un total. Algo en la arquitectura debe asignarle un tipo a ese total, y ahí comienza la comparación.

Las mismas dos tablas, dos veces

Cree dos carpetas de proyecto separadas conectadas a la misma instancia de PostgreSQL, y asigne a cada una su propio nombre de esquema. Compartir tablas entre dos ORMs genera filas duplicadas que parecen datos de rendimiento pero en realidad son errores.

En Drizzle, el esquema se encuentra en src/schema.ts como un archivo TypeScript normal. Obsérvese que los nombres de las columnas se declaran explícitamente en formato snake_case (user_id) mientras que la propiedad se escribe en camelCase (userId), y que la clave foránea es una referencia a función users.id:

import { integer, pgTable, uuid, varchar } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: uuid("id").primaryKey().defaultRandom(),
  email: varchar("email", { length: 255 }).notNull().unique(),
});

export const invoices = pgTable("invoices", {
  id: uuid("id").primaryKey().defaultRandom(),
  userId: uuid("user_id").notNull().references(() => users.id),
  total: integer("total").notNull(),
});

En Prisma, el mismo modelo se ubica en prisma/schema.prisma. La relación se declara en ambos lados: User tiene un array invoices, y Invoice contiene el valor escalar userId además de un atributo @relation que lo vincula con User:

model User {
  id       String    @id @default(uuid())
  email    String    @unique
  invoices Invoice[]
}

model Invoice {
  id     String @id @default(uuid())
  userId String
  total  Int
  user   User   @relation(fields: [userId], references: [id])
}

Ahora la consulta importante: obtener las facturas de un usuario por correo electrónico. Drizzle la expresa mediante una unión interna explícita con una cláusula where, mientras que Prisma solicita al usuario y agrega “incluir las facturas”:

// drizzle
const rows = await db
  .select()
  .from(invoices)
  .innerJoin(users, eq(invoices.userId, users.id))
  .where(eq(users.email, email));

// prisma
const user = await prisma.user.findUnique({
  where: { email },
  include: { invoices: true },
});

Los tipos de resultado reflejan esos dos modelos mentales. Drizzle devuelve filas con la estructura propia de una unión, con una clave users y una clave invoices en cada fila. Prisma devuelve User & { invoices: Invoice[] }, un objeto anidado. Ambos son correctos. La estructura de Drizzle imita al SQL; la estructura de Prisma imita la página que se va a renderizar.

Cuando está activo el registro de consultas, la diferencia persiste. La salida de Drizzle es una unión que un desarrollador puede leer directamente. La salida de Prisma es perfectamente utilizable, pero se trata de SQL generado que no se querría editar a mano.

Las migraciones fueron sin problemas para un esquema de este tamaño. drizzle-kit generate generó archivos SQL que se pueden commitar; prisma migrate creó su propio historial de migraciones que también se puede commitar. Ninguna de las herramientas tuvo dificultades con dos tablas, y un esquema de esta magnitud no permite identificar los casos de migración más complejos, por lo que no se debe sacar ninguna conclusión al respecto.

Reproducir el laboratorio localmente

Instale cada conjunto de herramientas en su propia carpeta. Drizzle necesita el ORM, un controlador (aquí postgres) y drizzle-kit para las migraciones; Prisma necesita la CLI y el cliente, además de prisma init para crear el archivo del esquema:

pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit

pnpm add prisma @prisma/client
pnpm exec prisma init

En cada carpeta, inserte un usuario y dos facturas, ejecute la operación de unión una vez e imprima el total de la primera factura junto con su tipo en tiempo de ejecución. Observe las diferentes rutas de acceso: rows[0].invoices.total para las filas resultantes de la unión en Drizzle, frente a user.invoices[0].total para el objeto anidado en Prisma.

console.log(rows[0]?.invoices.total, typeof rows[0]?.invoices.total);
console.log(user?.invoices[0]?.total, typeof user?.invoices[0]?.total);

Si una herramienta muestra string y otra number, la causa es casi siempre la asignación de tipos del controlador de base de datos, no la filosofía del ORM. Los controladores de PostgreSQL suelen devolver columnas bigint y numeric como cadenas de texto para evitar perder precisión en un número de JavaScript, mientras que las columnas integer simples se devuelven como números. Un total en formato de cadena es la forma en que "1200" + 50 se convierte silenciosamente en "120050" en una factura. Registre el resultado de typeof antes de elegir una biblioteca.

Ejecutar el archivo de consulta con TypeScript nativo

A continuación, verifique si el código se ejecuta directamente con la eliminación automática de tipos de Node, que procesa los archivos .ts borrando las anotaciones de tipo sin necesidad de un proceso de compilación separado:

node src/query.ts

Un módulo de Drizzle compuesto únicamente por funciones y anotaciones de tipo funcionó sin problemas. Un cliente de Prisma generado en node_modules también funcionó al ser llamado desde un pequeño wrapper. El problema surgió con un archivo que importaba los enums generados por Prisma al estilo antiguo. Las declaraciones enum de TypeScript no son solo tipos; se compilan en objetos en tiempo de ejecución, y el modo de eliminación exclusiva de Node no puede borrarlos, por lo que la ejecución falla. Eso no es un defecto de Prisma, sino la naturaleza del código generado en tiempo de ejecución. Si su versión de Prisma utiliza el motor y generador más reciente basado en TypeScript, examine qué emite realmente prisma generate antes de asumir que esto sigue siendo aplicable, y especifique la versión que probó.

Habilitar el registro de consultas

Al adivinar cómo es el SQL, los incidentes se alargan. Ambas bibliotecas pueden registrar cada consulta: Drizzle a través de la opción logger y Prisma mediante el array log en el cliente:

const db = drizzle(client, { logger: true });
const prisma = new PrismaClient({ log: ["query"] });

Coloque las dos cadenas SQL registradas junto a los dos resultados de typeof total. Esas cuatro líneas constituyen todo el conjunto de datos que necesita este laboratorio.

Costo de cada herramienta

Los compromisos se manifiestan en cinco aspectos.

Tipos. El comando include de Prisma generó exactamente la estructura que requería la página /invoices. La operación de unión de Drizzle produjo exactamente la estructura necesaria al depurar por qué el total se duplicaba. Ambas son útiles en diferentes situaciones, lo cual es un argumento a favor de elegir una para cada base de datos, y no de utilizar ambas con las mismas tablas.

Visibilidad de SQL. Cuando el total parece incorrecto, el registro de Drizzle resuelve el problema más rápido porque la consulta es legible. Cuando un nuevo miembro del equipo necesita agregar un campo, el archivo de esquema de Prisma es la opción más rápida. Se trata de situaciones diferentes con soluciones distintas.

El paso de generación. Prisma requiere ejecutar prisma generate después de cada cambio en el esquema; Drizzle exige que schema.ts permanezca preciso. Es fácil olvidarse del paso de generación en los entornos CI, y que un cliente esté una versión por detrás del esquema provoca fallos confusos. Haga que el CI falle cuando se omita la generación.

Rendimientos en entornos edge. La compatibilidad de Drizzle con entornos edge es un verdadero punto a favor, pero solo importa si despliega su aplicación en dichos entornos. Un proceso Node ejecutado junto a PostgreSQL en un VPS no se beneficia de ello, así que no deje que ese argumento decida sobre una aplicación alojada en servidor.

Límites de los paquetes. Ninguno de los ORM debe encontrarse en un componente cliente. Si alguno de ellos se importa a un módulo "use client", como en el caso de un filtro interactivo para tablas, el límite del cliente se establece en una posición demasiado alta y un controlador de base de datos llega al navegador. El artículo sobre cómo dibujar correctamente el límite use client explica cómo solucionar este problema.

Factura desglosada

Desglose más detallado de los costos:

  • Tiempo. La fricción generada por Drizzle se debía a la forma en que se realizaba la unión de datos: rows[0].invoices.total o rows[0].total, dependiendo de cómo estuviera escrita la consulta. La fricción de Prisma se originaba al tener que regenerar los datos después de cada modificación en el esquema.
  • Comportamiento en tiempo de ejecución. Tanto al insertar como al unir datos, se generan dos facturas. Dos tablas nunca determinarán a un ganador.
  • Enum. Los enum generados por Prisma son valores en tiempo de ejecución. Ejecutar esos archivos sin procesar el tipo Node resulta fallido; es necesario compilar ese paquete o evitar ejecutar directamente los archivos generados.
  • Vinculación al sistema. El cliente de Prisma es un producto con su propio generador y motor; las tablas de Drizzle son simplemente TypeScript. Abandonar cualquiera de ellos después de un año implica reescribir la capa de consultas, no cambiar una configuración. Incluyan esto en el RFC antes de que alguien escriba “siempre podemos cambiarlo más tarde”.
  • Elegir y qué no hacer

    Elija Drizzle cuando desee que el SQL sea visible en las revisiones de código y el equipo ya piense en términos de uniones. Mantenga el esquema en schema.ts y asegúrese de que haya alguien en el equipo que se sienta cómodo leyendo innerJoin.

    Elija Prisma cuando los hábitos del equipo estén basados en schema.prisma y include. Asigne recursos para la fase de generación en CI y haga que el pipeline falle si no se ejecuta.

    Evite lo siguiente independientemente de su elección:

    • Ejecutar ambos ORM contra las mismas tablas de producción “para comparar”. Así es como un total puede escribirse dos veces y alguien pasa todo el día conciliando facturas con los registros bancarios.
    • Elegir según las descargas semanales. Elija según el tipo de resultado de la unión que pueda leer rápidamente bajo presión.
  • Importar el cliente de la base de datos en una Acción del Servidor y también en un Componente del Cliente por comodidad. Esa comodidad es precisamente lo que hace que una isla de cliente acabe enviando un controlador.
  • Un total de tipo cadena que en realidad era un problema con los controladores

    Un fallo real muestra por qué es importante la verificación de typeof. Un equipo modela las mismas dos tablas en ambos herramientas, relaciona a un usuario con dos facturas y registra el tipo de total: number en ambos casos. Una semana después, se introduce un controlador diferente que mapea una columna numérica a string, y un informe comienza a concatenar en lugar de sumar, duplicando las cifras que muestra.

    La solución tentadora es envolver Number(total) alrededor de cada lugar donde se realiza la llamada. Eso oculta el problema en lugar de solucionarlo, y la siguiente columna con el mismo issue pasará desapercibida. La solución duradera consiste en registrar la consulta SQL y el tipo de resultado una vez por biblioteca, fijar la versión del controlador y evitar que dos ORMs escriban en las mismas tablas de producción.

    El manejo de enums sigue la misma lógica: los enums generados son código en tiempo de ejecución, por lo que se debe compilar ese paquete y ejecutar el resultado de dist/ en lugar de ejecutar directamente el TypeScript generado. Y sea cual sea la biblioteca elegida, se debe escribir la decisión y el motivo en el README, para que nadie añada la otra más tarde solo para probarla.

    Registrar el entorno antes de comparar

    Resultados como estos solo tienen sentido junto con las versiones que los generaron. El entorno de referencia aquí fue Node 24, TypeScript 7 y Next.js 16.3, ejecutando una pequeña aplicación de facturas con cuatro rutas. Mantenga un archivo notes/lab.md en el repositorio y comience registrando las tres versiones:

    node -v
    pnpm exec tsc -v
    pnpm exec next --version
    

    Escriba ellas al principio de la nota. Si una versión importante difiere de la que asume la guía, deténgase y concilie las diferencias antes de ejecutar cualquier otra cosa, ya que los comandos posteriores podrían engañarlo de formas sutiles.

    Luego inicie el servidor de desarrollo y recorra las rutas:

    pnpm exec next dev
    

    Visite /, /invoices, /invoices/1, /settings, y luego nuevamente /invoices, con la opción “Preservar registro” activada en DevTools. Registre el cuadro de filtros junto con la URL; esa combinación suele ser la evidencia que necesitará más adelante.

    Luego ejecute el verificador de tipos e imprima su código de salida:

    pnpm exec tsc --noEmit --pretty false
    echo $?
    

    Un código de salida cero no es una ventaja, sino solo el permiso para pasar a las verificaciones en tiempo de ejecución. Después de eso, ejecute los comandos de la sección de laboratorio anterior en su propia máquina en lugar de confiar en estos resultados; el hardware, la presión de memoria y lo que haga el navegador pueden alterar el uso de memoria, la duración de las verificaciones de tipo y los tiempos de carga más que una versión menor del framework.

    También es útil mantener una entrada de una línea con la solución fallida en las notas, en el formato “intenté X, pero aún se observó Y”. Esa línea convierte el archivo en un registro honesto del laboratorio en lugar de un folleto, y es lo más útil para entregar a un colega que continúe con la investigación.

    Errores que vale la pena evitar

    Tres errores comunes en este tipo de comparaciones se repiten con frecuencia:

    • Ejecutar ambas herramientas de migración contra la misma base de datos para compararlas, lo que deja dos historiales de migración y una tabla con dos nombres diferentes. La única forma limpia de recuperarse es mediante restauración desde una copia de seguridad.
    • Importar los enums generados por Prisma a un archivo que luego se procesa con la función de eliminación de tipos de Node, lo cual falla por las razones mencionadas anteriormente. En su lugar, compila ese paquete.
    • Juzgar las bibliotecas según su cantidad de descargas, lo cual no tiene relación con el tipo de unión que se utilice.

    Lista de verificación antes de agregar un ORM

    • Un ORM por cada base de datos.
    • El SQL correspondiente a la unión clave debe estar registrado al menos una vez.
    • El valor de typeof en tiempo de ejecución de las columnas relacionadas con dinero debe registrarse al menos una vez, y nuevamente después de cada actualización del controlador.
    • El resultado generado que contiene enums debe ser compilado, nunca ejecutado mediante la eliminación directa de tipos.
    • El archivo README debe indicar el nombre de la biblioteca elegida y la razón de su elección.

    Conclusión

    Dos tablas no constituyen un esquema de producción, y este laboratorio no realizó pruebas con miles de uniones ni las desplegó en un entorno de ejecución periférico. Lo que sí demuestra es que las diferencias decisivas son concretas y se pueden verificar en una tarde: la forma del resultado de la unión, la legibilidad del SQL registrado, el costo del paso de generación y si un controlador devuelve números o cadenas de texto. Elija una biblioteca por base de datos, anote el motivo y haga que la comprobación typeof total sea un ritual después de cada actualización del controlador. Dejar que dos herramientas de migración gestionen una sola base de datos termina en una restauración, así que mantenga ese experimento lejos de cualquier sistema que maneje dinero real.

    Lecturas relacionadas

  • Baselining de una base de datos existente en Prisma sin ejecutar migrate reset — Aprenda por qué Prisma informa sobre desviaciones en una base de datos existente, por qué migrate reset no es la solución adecuada, y cómo realizar el baseline con db pull, migrate diff y migrate resolve.