Drizzle ORM: SQL Type-Safe per PostgreSQL — Migrazioni, Prepared Statement e Confronto con Prisma
L'Idea Fondamentale: SQL come Cittadino di Prima Classe in TypeScript
La filosofia di Drizzle è semplice: SQL è già un buon linguaggio di query. Non astrarlo via — rendilo type-safe:
import { pgTable, serial, text, timestamp, integer, boolean } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name').notNull(),
role: text('role', { enum: ['admin', 'user', 'viewer'] }).notNull().default('user'),
createdAt: timestamp('created_at').defaultNow().notNull(),
lastLoginAt: timestamp('last_login_at'),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content').notNull(),
published: boolean('published').notNull().default(false),
authorId: integer('author_id').notNull().references(() => users.id),
viewCount: integer('view_count').notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const comments = pgTable('comments', {
id: serial('id').primaryKey(),
body: text('body').notNull(),
postId: integer('post_id').notNull().references(() => posts.id),
authorId: integer('author_id').notNull().references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
comments: many(comments),
}));
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
comments: many(comments),
}));
Ora guarda cosa succede quando fai query. Ogni campo, ogni join, ogni condizione è completamente type-checked:
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
import { eq, desc, sql, count } from 'drizzle-orm';
import * as schema from './schema';
const queryClient = neon(process.env.DATABASE_URL!);
const db = drizzle(queryClient, { schema });
const activeUsers = await db
.select({
id: schema.users.id,
email: schema.users.email,
name: schema.users.name,
})
.from(schema.users)
.where(eq(schema.users.role, 'admin'))
.orderBy(desc(schema.users.createdAt))
.limit(10);
// TypeScript sa: activeUsers è Array<{ id: number; email: string; name: string }>
// Prova ad accedere a activeUsers[0].role — errore di compilazione! Non è stato selezionato.
Questa è la differenza chiave da Prisma. In Drizzle, anche i frammenti SQL raw possono portare informazioni di tipo:
const topAuthors = await db
.select({
authorId: schema.posts.authorId,
authorName: schema.users.name,
postCount: count(schema.posts.id).as('post_count'),
totalViews: sql<number>`sum(${schema.posts.viewCount})`.as('total_views'),
avgViews: sql<number>`avg(${schema.posts.viewCount})::integer`.as('avg_views'),
})
.from(schema.posts)
.innerJoin(schema.users, eq(schema.posts.authorId, schema.users.id))
.where(eq(schema.posts.published, true))
.groupBy(schema.posts.authorId, schema.users.name)
.having(gt(count(schema.posts.id), 5))
.orderBy(desc(sql`sum(${schema.posts.viewCount})`))
.limit(20);
Quel tag template literal sql<number> è geniale. Scrivi SQL reale ma annoti il tipo di ritorno, e Drizzle lo propaga attraverso l'intera inferenza dei tipi della query.
Strategie di Migrazione: Dove Drizzle Kit Eccelle
drizzle-kit push — La Modalità Sviluppo
npx drizzle-kit push
# Legge i tuoi file schema, confronta con il database reale,
# genera e applica ALTER statement direttamente
drizzle-kit generate + migrate — La Modalità Produzione
npx drizzle-kit generate
# Crea: drizzle/0003_add_post_slug.sql
Puoi modificare i file generati — aggiungere migrazioni dati, wrappare in transazioni:
BEGIN;
ALTER TABLE "posts" ADD COLUMN "slug" text;--> statement-breakpoint
UPDATE "posts" SET "slug" = lower(
regexp_replace(
regexp_replace("title", '[^a-zA-Z0-9\s-]', '', 'g'),
'\s+', '-', 'g'
)
);--> statement-breakpoint
ALTER TABLE "posts" ALTER COLUMN "slug" SET NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "posts_slug_idx" ON "posts" ("slug");--> statement-breakpoint
COMMIT;
Prepared Statement e la Storia dell'Edge Runtime
Poiché non c'è binary del query engine, Drizzle gira ovunque giri JavaScript:
// Cloudflare Workers — funziona direttamente
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const sql = neon(env.DATABASE_URL);
const db = drizzle(sql);
const getUser = db
.select()
.from(schema.users)
.where(eq(schema.users.id, sql.placeholder('userId')))
.prepare('get_user_by_id');
const user = await getUser.execute({ userId: 42 });
return Response.json(user);
}
};
Il Confronto Onesto con Prisma
Prisma è un buon strumento con anni di validazione in produzione. Ma ecco dove i due differiscono genuinamente:
Tempo di Cold Start
Cold start di Prisma (AWS Lambda, Node.js 20):
- Caricamento binary query engine: ~180ms
- Parsing schema: ~45ms
- Prima query: ~80ms
Totale: ~305ms
Cold start di Drizzle (stesso ambiente):
- Import modulo: ~12ms
- Prima query: ~75ms
Totale: ~87ms
Quei ~220ms di differenza sono il query engine di Prisma che si carica.
Generazione Query
// Prisma genera: query multiple separate (potenziale 1+N)
// SELECT * FROM "posts" WHERE "published" = true
// SELECT * FROM "users" WHERE "id" IN (...)
// SELECT * FROM "comments" WHERE "post_id" IN (...)
// Drizzle genera: lateral join dove possibile
// SELECT ... FROM "posts"
// LEFT JOIN LATERAL (
// SELECT ... FROM "comments"
// WHERE "comments"."post_id" = "posts"."id"
// ORDER BY "created_at" DESC LIMIT 5
// ) ON true
// LEFT JOIN "users" ON "users"."id" = "posts"."author_id"
L'approccio lateral join è una singola query che PostgreSQL può ottimizzare olisticamente.
Transazioni e Gestione Errori
const newPost = await db.transaction(async (tx) => {
const [author] = await tx
.select()
.from(schema.users)
.where(eq(schema.users.id, authorId))
.for('update');
if (!author) throw new Error('Autore non trovato');
const [post] = await tx
.insert(schema.posts)
.values({
title,
content,
authorId: author.id,
published: false,
})
.returning();
await tx
.update(schema.users)
.set({ lastLoginAt: new Date() })
.where(eq(schema.users.id, authorId));
return post;
});
Limitazioni Notevoli
Dopo un uso esteso in produzione con Drizzle, le limitazioni notevoli sono:
-
Il query builder relazionale (
db.query.*) è meno flessibile del builder SQL-like. Per query complesse, scendo sempre all'APIselect/from/where. -
I messaggi di errore dal diff dello schema possono essere criptici. Quando
drizzle-kit pushrileva un cambio di tipo colonna, l'errore a volte non dice quale tabella o colonna è cambiata. -
Nessun connection pooling integrato. Devi portare il tuo (
postgres.jspool, driver serverless di Neon, ecc.).
Ma questi sono graffi superficiali confrontati con i benefici fondamentali: zero dipendenze binary, type safety SQL genuino, e la libertà di deployare ovunque JavaScript giri. Drizzle rappresenta la direzione che lo spazio ORM doveva prendere.