Vai al contenuto principale
Backend

Drizzle ORM: SQL Type-Safe per PostgreSQL — Migrazioni, Prepared Statement e Confronto con Prisma

5 min lettura
LD
Lucio Durán
Engineering Manager & AI Solutions Architect
Disponibile anche in: English, Español

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:

  1. Il query builder relazionale (db.query.*) è meno flessibile del builder SQL-like. Per query complesse, scendo sempre all'API select/from/where.

  2. I messaggi di errore dal diff dello schema possono essere criptici. Quando drizzle-kit push rileva un cambio di tipo colonna, l'errore a volte non dice quale tabella o colonna è cambiata.

  3. Nessun connection pooling integrato. Devi portare il tuo (postgres.js pool, 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.

drizzleormpostgresqltype-safe-sqlprismakyselyedge-runtimemigrazioni

Strumenti menzionati in questo articolo

SupabaseProva Supabase
NeonProva Neon
Divulgazione: Alcuni link in questo articolo sono link di affiliazione. Se ti registri tramite questi, potrei guadagnare una commissione senza costi aggiuntivi per te. Raccomando solo strumenti che uso e di cui mi fido personalmente.
Compartir
Seguime