kodingup.camp

Beranda/Node.js Backend: Database, Auth, Keamanan & Deploy

ORM & Query Builder

Modul 2 dari 107 menit bacaORM with Drizzle

Isi modul

    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
    

    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:

    1. Ubah file skema.
    2. Jalankan npx drizzle-kit generate --name=nama_perubahan. drizzle-kit membandingkan skema sekarang dengan snapshot terakhir, lalu menulis file SQL baru di folder drizzle/ (misalnya 0000_init.sql, 0001_add_category.sql) plus catatan di drizzle/meta/.
    3. Terapkan migrasi ke database. Bisa lewat npx drizzle-kit migrate, atau dari kode aplikasi dengan function migrate() 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

    1. Memakai async di transaksi better-sqlite3. better-sqlite3 sinkron, dan Drizzle menolak callback async. Hasil uji: Transaction function cannot return a promise. Di dalam transaksi SQLite, jangan await apa pun. Lakukan pekerjaan async (misalnya hash password) sebelum transaksi dimulai. Untuk Postgres justru sebaliknya: callback-nya wajib async.
    2. 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.
    3. Memakai sql.raw() dengan input user. sql.raw() menempel teks apa adanya, persis seperti contoh "❌ SALAH" di atas. Pakai ${nilai} di dalam sql`...` yang otomatis jadi parameter.
    4. Lupa .all()/.get()/.run() atau await. Tanpa salah satunya, query hanya dirakit, tidak dijalankan. Variabelmu berisi objek query, bukan data.
    5. 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 >= jumlah di dalam transaksi (dipakai di Proyek Akhir).

    Latihan

    1. Mudah, Tulis query Drizzle untuk mengambil nama dan harga produk dengan harga antara 50.000 dan 100.000, urut dari yang termurah.
    2. Sedang, Tambahkan kolom category (teks, wajib, default 'umum') ke tabel products. Buat migrasinya dan tunjukkan SQL yang dihasilkan.
    3. 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

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    Learn Drizzle In 60 MinutesWeb Dev Simplified · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.