Kenapa ini penting
Menulis SQL mentah seperti di Modul 1 itu bagus untuk belajar, tapi di aplikasi besar cepat merepotkan. Nama kolom ditulis sebagai string, jadi salah ketik pirce baru ketahuan saat program jalan. Hasil query berupa objek tanpa bentuk yang jelas. Setiap kali tabel berubah, kamu harus mengingat sendiri perintah ALTER TABLE apa yang sudah dan belum dijalankan di laptopmu, di laptop temanmu, dan di server produksi.
ORM (Object-Relational Mapper) dan query builder menyelesaikan masalah itu. Kamu mendefinisikan tabel sebagai kode JavaScript, menulis query dengan function (db.select().from(products)), dan alat bantunya membuatkan migrasi: file SQL bernomor yang mencatat setiap perubahan struktur tabel, seperti riwayat commit untuk database. Analoginya: SQL mentah itu seperti menulis surat dengan tangan, ORM itu seperti formulir yang kolomnya sudah dicetak. Tetap kamu yang mengisi, tapi kecil kemungkinan ada kolom yang terlewat.
Di materi ini kita memakai Drizzle ORM. Drizzle sengaja dibuat "tipis": query-nya mirip sekali dengan SQL, jadi pengetahuan dari Modul 1 langsung terpakai, dan kamu selalu bisa melihat SQL apa yang dijalankan.
Konsep inti
Paket yang dipakai dan status versinya
npm install drizzle-orm better-sqlite3
npm install -D drizzle-kit
drizzle-orm0.45.4: library query yang dipakai saat aplikasi berjalan. Taglatestdi npm saat ini masih 0.45.4. Drizzle 1.0 sedang di tahap release candidate (1.0.0-rc.5, tagrc) dengan API relasi baru bernamadefineRelations. Kalau kamu membaca dokumentasi resmi dan menemukandefineRelations, itu untuk versi 1.0. Di versi 0.45 yang kita pakai, relasi didefinisikan dengan functionrelations()(dicek langsung dari file tipe dinode_modules).drizzle-kit0.31.11: alat CLI untuk membuat file migrasi dari skema.better-sqlite313.0.3: driver SQLite yang cepat dan sinkron. Drizzle 0.45 belum punya driver untuknode:sqlitebawaan (dukungan itu baru ada di cabang 1.0), jadi kita pakai better-sqlite3. Ini paket native, dan untuk platform umum (macOS, Linux, Windows) binary-nya sudah tersedia siap pakai.
Alternatif populer adalah Prisma. Hati-hati: saat dicek, tag latest paket prisma di npm menunjuk ke 8.0.0-rc.27, versi release candidate. Artinya npm install prisma tanpa nomor versi akan memasang versi RC. Versi stabil terbarunya adalah 7.10.1 (tag prev). Kalau kamu memilih Prisma, pasang dengan versi eksplisit (npm install prisma@7.10.1). Prisma memakai file skema sendiri (schema.prisma) dan menghasilkan client, sedangkan Drizzle menulis skema langsung sebagai JavaScript.
Skema: tabel sebagai kode
Skema adalah file JavaScript yang mendeskripsikan tabel. Setiap kolom dibuat dengan function sesuai tipenya (integer, text), lalu diberi aturan berantai: .notNull(), .default(), .unique(), .references(). Argumen pertama tiap kolom adalah nama kolom di database (biasanya snake_case), sedangkan nama properti di JavaScript bebas (biasanya camelCase). Drizzle menerjemahkan keduanya otomatis.
Migrasi
Alurnya selalu sama:
- Ubah file skema.
- Jalankan
npx drizzle-kit generate --name=nama_perubahan. drizzle-kit membandingkan skema sekarang dengan snapshot terakhir, lalu menulis file SQL baru di folderdrizzle/(misalnya0000_init.sql,0001_add_category.sql) plus catatan didrizzle/meta/. - Terapkan migrasi ke database. Bisa lewat
npx drizzle-kit migrate, atau dari kode aplikasi dengan functionmigrate()saat server start. Drizzle mencatat migrasi mana yang sudah dijalankan di tabel khusus__drizzle_migrations, jadi migrasi yang sama tidak akan jalan dua kali.
File migrasi di-commit ke Git. Dengan begitu, laptop temanmu dan server produksi menjalankan perubahan yang persis sama, dengan urutan yang sama.
Ada juga npx drizzle-kit push yang langsung menyamakan database dengan skema tanpa membuat file migrasi. Ini praktis untuk eksperimen di laptop, tapi jangan dipakai di produksi karena tidak ada catatan perubahannya.
Query: sinkron vs asinkron
Karena better-sqlite3 sinkron, query Drizzle untuk driver ini punya method penutup yang langsung mengembalikan hasil:
| Method | Hasil | Dipakai untuk |
|---|---|---|
.all() |
Array semua baris | SELECT banyak baris |
.get() |
Satu baris atau undefined |
SELECT satu baris, INSERT ... returning() satu baris |
.run() |
{ changes, lastInsertRowid } |
INSERT/UPDATE/DELETE tanpa perlu data balik |
Query juga bisa di-await (Drizzle membuatnya "thenable"), tapi dengan better-sqlite3 tidak ada keuntungan. Di driver Postgres semuanya asinkron, jadi kamu akan menulis await db.select()... dan transaksi dengan async (tx) => {...}.
Untuk kondisi WHERE, Drizzle menyediakan operator sebagai function: eq (sama dengan), ne, gt, gte, lt, lte, like, between, inArray, and, or, isNull. Untuk ekspresi khusus ada template sql`...`.
Relasi dan relational query
Foreign key (.references()) menjaga integritas di database. Tapi supaya Drizzle bisa mengambil data bertingkat (user beserta pesanan beserta item-nya) dalam satu panggilan, kamu juga mendeklarasikan relasi dengan relations(). Setelah skema dan relasi diberikan ke drizzle({ client, schema }), kamu bisa memakai db.query.namaTabel.findMany({ with: {...} }) atau findFirst(). Untuk driver sinkron, tutup dengan .sync().
Transaksi
Transaksi mengelompokkan beberapa query menjadi satu kesatuan: semua berhasil (commit) atau semua dibatalkan (rollback). Contoh klasik: checkout. Membuat pesanan, membuat item pesanan, dan mengurangi stok harus terjadi bersamaan. Kalau stok produk kedua ternyata habis, pesanan dan pengurangan stok produk pertama harus ikut batal. Di Drizzle, db.transaction((tx) => {...}) melakukan rollback otomatis kalau ada error yang dilempar di dalamnya.
SQL injection dan parameterized query
SQL injection terjadi saat input pengguna ditempel langsung ke string SQL, sehingga penyerang bisa "menyisipkan" perintah SQL sendiri. Ini termasuk celah keamanan paling tua sekaligus paling sering ditemukan. Solusinya selalu sama: parameterized query. Struktur SQL dikirim terpisah dari datanya (placeholder ? atau $1), sehingga database memperlakukan input sebagai data murni, bukan sebagai perintah.
Drizzle selalu memakai parameter untuk nilai yang kamu berikan ke eq(), like(), .values(), dan juga untuk ${nilai} di dalam template sql`...`. Yang tidak aman adalah sql.raw() dengan input pengguna. Function itu memang dibuat untuk menyisipkan teks apa adanya.
Contoh kode
Pertama, lihat sendiri bagaimana injection bekerja dan bagaimana parameter menangkalnya:
// injection.js, kenapa query TIDAK boleh dirakit dari string input user
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, role TEXT);
INSERT INTO users (email, role) VALUES ('admin@toko.id','admin'), ('budi@mail.com','user');`);
const inputJahat = "x' OR '1'='1"; // diketik penyerang di kolom pencarian
// ❌ SALAH: input ditempel langsung ke SQL
const bahaya = `SELECT email, role FROM users WHERE email = '${inputJahat}'`;
console.log(bahaya);
// → SELECT email, role FROM users WHERE email = 'x' OR '1'='1'
console.log(db.prepare(bahaya).all().length); // → 2 (SEMUA user bocor!)
// ✅ BENAR: parameterized query, input dikirim terpisah sebagai DATA
console.log(db.prepare('SELECT email, role FROM users WHERE email = ?').all(inputJahat).length);
// → 0 (yang dicari email yang persis "x' OR '1'='1", tentu tidak ada)
Sekarang skema toko yang juga dipakai di Proyek Akhir. Simpan sebagai src/db/schema.js:
// src/db/schema.js, struktur tabel toko online (drizzle-orm 0.45.4)
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';
import { relations, sql } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
email: text('email').notNull().unique(),
name: text('name').notNull(),
passwordHash: text('password_hash').notNull(),
role: text('role', { enum: ['user', 'admin'] }).notNull().default('user'),
createdAt: text('created_at').notNull().default(sql`(CURRENT_TIMESTAMP)`),
});
export const products = sqliteTable('products', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
description: text('description').notNull().default(''),
price: integer('price').notNull(), // dalam rupiah, integer (hindari float untuk uang)
stock: integer('stock').notNull().default(0),
imageUrl: text('image_url'),
});
export const orders = sqliteTable('orders', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => users.id),
total: integer('total').notNull(),
status: text('status', { enum: ['pending', 'paid', 'shipped'] }).notNull().default('pending'),
createdAt: text('created_at').notNull().default(sql`(CURRENT_TIMESTAMP)`),
});
export const orderItems = sqliteTable('order_items', {
id: integer('id').primaryKey({ autoIncrement: true }),
orderId: integer('order_id').notNull().references(() => orders.id, { onDelete: 'cascade' }),
productId: integer('product_id').notNull().references(() => products.id),
quantity: integer('quantity').notNull(),
price: integer('price').notNull(), // harga SAAT dibeli, bukan harga sekarang
});
// relasi untuk db.query.*.findMany({ with: ... })
export const usersRelations = relations(users, ({ many }) => ({ orders: many(orders) }));
export const ordersRelations = relations(orders, ({ one, many }) => ({
user: one(users, { fields: [orders.userId], references: [users.id] }),
items: many(orderItems),
}));
export const orderItemsRelations = relations(orderItems, ({ one }) => ({
order: one(orders, { fields: [orderItems.orderId], references: [orders.id] }),
product: one(products, { fields: [orderItems.productId], references: [products.id] }),
}));
Konfigurasi drizzle-kit di root proyek, drizzle.config.js:
// drizzle.config.js
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'sqlite',
schema: './src/db/schema.js',
out: './drizzle',
dbCredentials: { url: process.env.DATABASE_FILE ?? 'toko.db' },
});
Buat migrasi pertama:
npx drizzle-kit generate --name=init
# → 4 tables
# → order_items 5 columns 0 indexes 2 fks
# → orders 5 columns 0 indexes 1 fks
# → products 6 columns 0 indexes 0 fks
# → users 6 columns 1 indexes 0 fks
# → [✓] Your SQL migration file ➜ drizzle/0000_init.sql 🚀
Isi drizzle/0000_init.sql adalah SQL biasa yang bisa kamu baca. Potongan aslinya:
CREATE TABLE `orders` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`total` integer NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
Selalu baca file migrasi sebelum di-commit, terutama kalau ada perintah yang menghapus kolom atau tabel.
Koneksi dan migrasi otomatis saat start, src/db/index.js:
// src/db/index.js, buka koneksi + jalankan migrasi
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import * as schema from './schema.js';
export function createDb(file) {
const sqlite = new Database(file); // ':memory:' untuk tes
sqlite.pragma('journal_mode = WAL'); // pembaca tidak menunggu penulis
sqlite.pragma('foreign_keys = ON'); // SQLite TIDAK menegakkan foreign key tanpa ini
const db = drizzle({ client: sqlite, schema });
migrate(db, { migrationsFolder: 'drizzle' }); // jalankan migrasi yang belum pernah dijalankan
return db;
}
Sekarang CRUD, transaksi, dan relasi dalam satu file uji crud-demo.js:
// crud-demo.js, CRUD, relasi, dan transaksi dengan Drizzle
import { eq, desc, sql } from 'drizzle-orm';
import { createDb } from './src/db/index.js';
import { users, products, orders, orderItems } from './src/db/schema.js';
const db = createDb(':memory:'); // migrasi dijalankan otomatis di dalam createDb
// CREATE, .returning() mengembalikan baris yang baru dibuat
const budi = db.insert(users)
.values({ email: 'budi@mail.com', name: 'Budi', passwordHash: 'x' })
.returning().get();
console.log(budi.id, budi.role); // → 1 user (role terisi dari default)
db.insert(products).values([
{ name: 'Kopi Gayo', price: 85000, stock: 10 },
{ name: 'Kopi Toraja', price: 95000, stock: 3 },
]).run();
// READ
console.log(db.select({ name: products.name, price: products.price })
.from(products).orderBy(desc(products.price)).all());
// → [ { name: 'Kopi Toraja', price: 95000 }, { name: 'Kopi Gayo', price: 85000 } ]
// UPDATE
const r = db.update(products).set({ price: 90000 }).where(eq(products.name, 'Kopi Toraja')).run();
console.log(r.changes); // → 1
// TRANSAKSI: buat pesanan + kurangi stok, semua-atau-tidak-sama-sekali
db.transaction((tx) => {
const o = tx.insert(orders).values({ userId: budi.id, total: 180000 }).returning().get();
tx.insert(orderItems).values({ orderId: o.id, productId: 2, quantity: 2, price: 90000 }).run();
tx.update(products).set({ stock: sql`${products.stock} - 2` }).where(eq(products.id, 2)).run();
const sisa = tx.select().from(products).where(eq(products.id, 2)).get().stock;
if (sisa < 0) throw new Error('stok minus');
console.log('stok Toraja di dalam transaksi:', sisa); // → stok Toraja di dalam transaksi: 1
});
// Transaksi yang GAGAL → semua perubahan di dalamnya dibatalkan
try {
db.transaction((tx) => {
tx.update(products).set({ stock: sql`${products.stock} - 5` }).where(eq(products.id, 2)).run();
const sisa = tx.select().from(products).where(eq(products.id, 2)).get().stock;
if (sisa < 0) throw new Error(`stok minus (${sisa})`);
});
} catch (e) { console.log('gagal:', e.message); } // → gagal: stok minus (-4)
console.log('stok Toraja sekarang:', db.select().from(products).where(eq(products.id, 2)).get().stock);
// → stok Toraja sekarang: 1 (pengurangan 5 tadi di-rollback)
// RELASI: ambil user beserta pesanan dan item-nya sekaligus
const data = db.query.users.findFirst({
where: eq(users.id, budi.id),
columns: { name: true },
with: { orders: { columns: { id: true, total: true }, with: { items: { with: { product: { columns: { name: true } } } } } } },
}).sync();
console.log(JSON.stringify(data));
// → {"name":"Budi","orders":[{"id":1,"total":180000,"items":[{"id":1,"orderId":1,"productId":2,
// "quantity":2,"price":90000,"product":{"name":"Kopi Toraja"}}]}]}
// DELETE, order_items ikut terhapus karena onDelete: 'cascade'
db.delete(orders).where(eq(orders.id, 1)).run();
console.log(db.select().from(orderItems).all()); // → []
Mau tahu SQL apa yang sebenarnya dikirim? Pakai .toSQL(). Contoh ini memakai tabel products sederhana dengan tiga kolom (id, name, price). Perhatikan input berbahaya masuk ke params, bukan ke teks SQL:
import { and, like, gte } from 'drizzle-orm';
const q = "kopi' OR 1=1 --";
console.log(db.select().from(products)
.where(and(like(products.name, `%${q}%`), gte(products.price, 50000))).toSQL());
// → { sql: 'select "id", "name", "price" from "products"
// where ("products"."name" like ? and "products"."price" >= ?)',
// params: [ "%kopi' OR 1=1 --%", 50000 ] }
Versi PostgreSQL
Untuk Postgres, impor dari drizzle-orm/pg-core dan drizzle-orm/node-postgres. Semua query menjadi asinkron. Potongan ini diuji memakai PGlite (Postgres yang berjalan di dalam proses Node), dengan skema dan API transaksi yang sama persis:
// db-pg.js, skema & koneksi PostgreSQL
import { pgTable, serial, text, integer, timestamp, pgEnum } from 'drizzle-orm/pg-core';
import { drizzle } from 'drizzle-orm/node-postgres';
import { eq } from 'drizzle-orm';
export const roleEnum = pgEnum('role', ['user', 'admin']);
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
role: roleEnum('role').notNull().default('user'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
const db = drizzle(process.env.DATABASE_URL); // membuat Pool pg di belakang layar
const [u] = await db.insert(users).values({ email: 'a@b.c' }).returning();
console.log(u.id, u.role, u.createdAt instanceof Date); // → 1 user true
// transaksi Postgres = async
const role = await db.transaction(async (tx) => {
await tx.update(users).set({ role: 'admin' }).where(eq(users.id, u.id));
return (await tx.select().from(users))[0].role;
});
console.log(role); // → admin
Di drizzle.config.js, ganti dialect: 'postgresql' dan dbCredentials: { url: process.env.DATABASE_URL }.
Kesalahan umum
- Memakai
asyncdi transaksi better-sqlite3. better-sqlite3 sinkron, dan Drizzle menolak callback async. Hasil uji:Transaction function cannot return a promise. Di dalam transaksi SQLite, janganawaitapa pun. Lakukan pekerjaan async (misalnya hash password) sebelum transaksi dimulai. Untuk Postgres justru sebaliknya: callback-nya wajib async. - Mengubah database langsung tanpa migrasi. Menambah kolom lewat aplikasi GUI database di laptop membuat skema laptopmu berbeda dari server. Selalu ubah
schema.js→drizzle-kit generate→ commit file migrasinya. - Memakai
sql.raw()dengan input user.sql.raw()menempel teks apa adanya, persis seperti contoh "❌ SALAH" di atas. Pakai${nilai}di dalamsql`...`yang otomatis jadi parameter. - Lupa
.all()/.get()/.run()atauawait. Tanpa salah satunya, query hanya dirakit, tidak dijalankan. Variabelmu berisi objek query, bukan data. - Mengecek stok lalu menguranginya di dua langkah terpisah, di luar transaksi. Dua pembeli bersamaan bisa sama-sama melihat "stok 1" lalu sama-sama membeli. Gabungkan cek dan kurangi dalam satu
UPDATE ... WHERE stock >= jumlahdi dalam transaksi (dipakai di Proyek Akhir).
Latihan
- Mudah, Tulis query Drizzle untuk mengambil nama dan harga produk dengan harga antara 50.000 dan 100.000, urut dari yang termurah.
- Sedang, Tambahkan kolom
category(teks, wajib, default'umum') ke tabelproducts. Buat migrasinya dan tunjukkan SQL yang dihasilkan. - Menantang, Buat query "produk terlaris": nama produk dan total kuantitas terjual dari tabel
order_items, urut dari yang terbanyak, maksimal 5 produk.
Lihat solusi
1. Operator between untuk rentang, asc untuk urutan naik.
import { between, asc } from 'drizzle-orm';
console.log(db.select({ name: products.name, price: products.price }).from(products)
.where(between(products.price, 50000, 100000)).orderBy(asc(products.price)).all());
// → [ { name: 'Kopi Gayo', price: 85000 }, { name: 'Kopi Toraja', price: 95000 } ]
2. Tambahkan satu baris di products, lalu generate.
// di dalam sqliteTable('products', { ... })
category: text('category').notNull().default('umum'),
npx drizzle-kit generate --name=add_category
# → [✓] Your SQL migration file ➜ drizzle/0001_add_category.sql 🚀
cat drizzle/0001_add_category.sql
# → ALTER TABLE `products` ADD `category` text DEFAULT 'umum' NOT NULL;
Karena ada DEFAULT, baris lama otomatis terisi 'umum', jadi NOT NULL tidak bermasalah.
3. JOIN + groupBy + agregat sum. .mapWith(Number) memastikan hasilnya bertipe number.
import { desc, eq, sql } from 'drizzle-orm';
const terjual = sql`sum(${orderItems.quantity})`.mapWith(Number);
console.log(db.select({ name: products.name, terjual })
.from(orderItems)
.innerJoin(products, eq(products.id, orderItems.productId))
.groupBy(products.id)
.orderBy(desc(terjual))
.limit(5)
.all());
// → [ { name: 'Kopi Gayo', terjual: 5 }, { name: 'French Press', terjual: 1 } ]
Ringkasan
- ORM/query builder seperti Drizzle membuat tabel sebagai kode, query sebagai function, dan migrasi sebagai file SQL bernomor yang di-commit ke Git.
- Alur perubahan skema: ubah
schema.js→drizzle-kit generate→ baca file SQL-nya → jalankanmigrate(). - Dengan better-sqlite3, query ditutup
.all(),.get(),.run(), atau.sync()untuk relational query. Transaksinya sinkron. Dengan Postgres, semuanyaawait. - Transaksi menjamin semua query berhasil bersama atau batal bersama. Error di dalam callback memicu rollback.
- SQL injection dicegah dengan parameterized query. Drizzle otomatis memakai parameter, kecuali di
sql.raw(). - Versi: drizzle-orm 0.45.4 (1.0 masih RC), drizzle-kit 0.31.11. Prisma stabil 7.10.1, sedangkan tag
latest-nya menunjuk ke 8.0.0-rc.27.