MySQL #

MySQL adalah database relasional yang paling banyak digunakan di dunia, dan mengintegrasikannya dengan TypeScript memberikan keunggulan yang signifikan: type safety membantu kamu menangkap ketidakcocokan tipe antara skema database dan kode aplikasi jauh sebelum runtime. Library pilihan utama untuk TypeScript adalah mysql2 — penerus dari mysql yang menawarkan Promise API native, dukungan prepared statements, dan performa yang jauh lebih baik. Memahami cara kerja connection pool, pentingnya parameterized query untuk mencegah SQL injection, serta bagaimana mendefinisikan tipe TypeScript yang merepresentasikan row hasil query adalah fondasi untuk membangun lapisan database yang aman dan mudah dipelihara.

Instalasi dan Setup #

Paket utama yang dibutuhkan adalah mysql2 beserta type definitions-nya. mysql2 sudah menyertakan tipe TypeScript bawaan, tapi untuk pengalaman terbaik kamu perlu memastikan TypeScript project sudah dikonfigurasi dengan benar.

# Instalasi paket
npm install mysql2

# mysql2 sudah include @types, tidak perlu instalasi terpisah
# Pastikan tsconfig.json memiliki konfigurasi yang tepat
// tsconfig.json — konfigurasi minimal yang direkomendasikan
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

Struktur project yang direkomendasikan untuk separasi concern yang baik:

src/
  ├── db/
  │   ├── connection.ts      -- konfigurasi pool dan singleton
  │   ├── migrations/        -- file migrasi skema
  │   └── seeds/             -- data awal untuk development
  ├── models/
  │   ├── user.model.ts      -- tipe dan query untuk tabel users
  │   └── product.model.ts
  ├── repositories/
  │   ├── user.repository.ts -- abstraksi akses data
  │   └── product.repository.ts
  └── index.ts

Koneksi Tunggal vs Connection Pool #

Ada dua cara utama terhubung ke MySQL: koneksi tunggal dan connection pool. Untuk aplikasi produksi, connection pool hampir selalu merupakan pilihan yang tepat.

import mysql, { Connection, Pool, PoolConnection } from 'mysql2/promise';

// ── Koneksi tunggal — hanya untuk skrip sekali jalan atau testing
async function koneksiTunggal(): Promise<void> {
  const conn: Connection = await mysql.createConnection({
    host: 'localhost',
    port: 3306,
    user: 'root',
    password: 'secret',
    database: 'toko_online',
  });

  try {
    const [rows] = await conn.query('SELECT 1 + 1 AS hasil');
    console.log(rows);
  } finally {
    await conn.end();  // WAJIB: selalu tutup koneksi
  }
}

// ── Connection pool — untuk aplikasi server/API
const pool: Pool = mysql.createPool({
  host: process.env.DB_HOST ?? 'localhost',
  port: Number(process.env.DB_PORT ?? 3306),
  user: process.env.DB_USER ?? 'root',
  password: process.env.DB_PASSWORD ?? '',
  database: process.env.DB_NAME ?? 'toko_online',
  waitForConnections: true,
  connectionLimit: 10,        // maksimum koneksi bersamaan
  queueLimit: 0,              // 0 = antrian tidak terbatas
  enableKeepAlive: true,
  keepAliveInitialDelay: 0,
});
flowchart TD
    A[Aplikasi butuh query] --> B{Pool punya\nkoneksi idle?}
    B -- Ya --> C[Ambil koneksi\ndari pool]
    B -- Tidak --> D{Jumlah koneksi\n< connectionLimit?}
    D -- Ya --> E[Buat koneksi\nbaru ke MySQL]
    D -- Tidak --> F[Masuk antrian\n tunggu]
    F --> G{Ada koneksi\nyang selesai?}
    G -- Ya --> C
    C --> H[Eksekusi query]
    E --> H
    H --> I[Kembalikan koneksi\nke pool]
    I --> J[Siap untuk\nrequest berikutnya]

Pola singleton untuk memastikan pool hanya dibuat sekali di seluruh aplikasi:

// src/db/connection.ts
import mysql, { Pool } from 'mysql2/promise';

let poolInstance: Pool | null = null;

export function getPool(): Pool {
  if (!poolInstance) {
    poolInstance = mysql.createPool({
      host: process.env.DB_HOST ?? 'localhost',
      port: Number(process.env.DB_PORT ?? 3306),
      user: process.env.DB_USER ?? 'root',
      password: process.env.DB_PASSWORD ?? '',
      database: process.env.DB_NAME ?? 'app_db',
      waitForConnections: true,
      connectionLimit: 10,
      timezone: '+07:00',  // WIB — penting untuk timestamp
    });
  }
  return poolInstance;
}

export async function closePool(): Promise<void> {
  if (poolInstance) {
    await poolInstance.end();
    poolInstance = null;
  }
}
Jangan lupa memanggil pool.end() saat aplikasi shutdown. Koneksi yang tidak ditutup menyebabkan proses Node.js tidak bisa berhenti dan menghabiskan koneksi di sisi MySQL server. Tambahkan handler untuk SIGTERM dan SIGINT di entry point aplikasi kamu.

Mendefinisikan Tipe untuk Row Database #

Salah satu keunggulan utama menggunakan TypeScript dengan MySQL adalah kemampuan mendefinisikan tipe yang merepresentasikan baris dari tabel. Ini membuat compiler bisa menangkap kesalahan akses kolom sebelum runtime.

import { RowDataPacket, ResultSetHeader } from 'mysql2';

// Definisi tipe untuk tabel users
// Extends RowDataPacket agar kompatibel dengan return type mysql2
interface User extends RowDataPacket {
  id: number;
  nama: string;
  email: string;
  password_hash: string;
  role: 'admin' | 'user' | 'moderator';
  aktif: boolean;
  dibuat_pada: Date;
  diperbarui_pada: Date;
}

interface Produk extends RowDataPacket {
  id: number;
  nama: string;
  deskripsi: string | null;
  harga: number;
  stok: number;
  kategori_id: number;
  gambar_url: string | null;
  dibuat_pada: Date;
}

// Tipe untuk operasi INSERT/UPDATE/DELETE
// ResultSetHeader mengandung insertId, affectedRows, dll.
interface HasilInsert extends ResultSetHeader {
  insertId: number;
  affectedRows: number;
}
// Penggunaan tipe saat query
import { getPool } from './db/connection';

async function cariUserById(id: number): Promise<User | null> {
  const pool = getPool();
  const [rows] = await pool.query<User[]>(
    'SELECT * FROM users WHERE id = ? AND aktif = true',
    [id]
  );
  return rows[0] ?? null;
}

// TypeScript sekarang tahu rows[0] adalah User
// Akses kolom yang tidak ada akan error saat compile
const user = await cariUserById(1);
if (user) {
  console.log(user.nama);   // ✓ TypeScript tahu ini string
  console.log(user.umur);   // ✗ Error compile: 'umur' tidak ada di User
}

Query Dasar — SELECT #

Membaca data dari MySQL menggunakan pool.query() atau pool.execute(). Perbedaan kunci: execute() menggunakan server-side prepared statements yang lebih aman dan efisien untuk query yang dieksekusi berulang kali.

import { getPool } from './db/connection';
import { User, Produk } from './types/db';

// ── SELECT semua baris
async function semuaUser(): Promise<User[]> {
  const pool = getPool();
  const [rows] = await pool.query<User[]>('SELECT id, nama, email, role FROM users');
  return rows;
}

// ── SELECT dengan kondisi — SELALU gunakan parameterized query!
async function cariUserByEmail(email: string): Promise<User | null> {
  const pool = getPool();
  const [rows] = await pool.execute<User[]>(
    'SELECT * FROM users WHERE email = ? AND aktif = true',
    [email]
  );
  return rows[0] ?? null;
}

// ── SELECT dengan multiple parameter
async function produkByKategoriDanHarga(
  kategoriId: number,
  hargaMin: number,
  hargaMax: number
): Promise<Produk[]> {
  const pool = getPool();
  const [rows] = await pool.execute<Produk[]>(
    `SELECT * FROM produk
     WHERE kategori_id = ?
       AND harga BETWEEN ? AND ?
       AND stok > 0
     ORDER BY harga ASC`,
    [kategoriId, hargaMin, hargaMax]
  );
  return rows;
}

// ── SELECT dengan LIKE — perlu escape karakter wildcard manual
async function cariProdukByNama(keyword: string): Promise<Produk[]> {
  const pool = getPool();
  // Escape % dan _ agar tidak diinterpretasi sebagai wildcard
  const safeKeyword = keyword.replace(/[%_\\]/g, '\\$&');
  const [rows] = await pool.execute<Produk[]>(
    'SELECT * FROM produk WHERE nama LIKE ? LIMIT 20',
    [`%${safeKeyword}%`]
  );
  return rows;
}

// ── SELECT dengan IN — untuk daftar ID
async function produkByIds(ids: number[]): Promise<Produk[]> {
  if (ids.length === 0) return [];
  const pool = getPool();
  const placeholders = ids.map(() => '?').join(', ');
  const [rows] = await pool.execute<Produk[]>(
    `SELECT * FROM produk WHERE id IN (${placeholders})`,
    ids
  );
  return rows;
}
// ANTI-PATTERN: string interpolasi langsung — RENTAN SQL INJECTION!
async function cariUserTidakAman(email: string): Promise<User[]> {
  const pool = getPool();
  // Jika email = "' OR '1'='1", query jadi bobol!
  const [rows] = await pool.query<User[]>(
    `SELECT * FROM users WHERE email = '${email}'`  // JANGAN!
  );
  return rows;
}

// BENAR: selalu gunakan parameterized query
async function cariUserAman(email: string): Promise<User | null> {
  const pool = getPool();
  const [rows] = await pool.execute<User[]>(
    'SELECT * FROM users WHERE email = ?',  // ? adalah placeholder
    [email]  // nilai dimasukkan terpisah, tidak bisa jadi SQL
  );
  return rows[0] ?? null;
}

Operasi INSERT #

Menyisipkan data baru ke MySQL dan mendapatkan ID yang baru dibuat.

import { ResultSetHeader } from 'mysql2';
import { getPool } from './db/connection';

interface InputUser {
  nama: string;
  email: string;
  passwordHash: string;
  role?: 'admin' | 'user' | 'moderator';
}

// ── INSERT tunggal
async function buatUser(input: InputUser): Promise<number> {
  const pool = getPool();
  const [result] = await pool.execute<ResultSetHeader>(
    `INSERT INTO users (nama, email, password_hash, role, aktif, dibuat_pada)
     VALUES (?, ?, ?, ?, true, NOW())`,
    [input.nama, input.email, input.passwordHash, input.role ?? 'user']
  );
  return result.insertId;  // ID auto-increment yang baru dibuat
}

// ── INSERT dengan objek langsung (lebih ringkas)
async function buatProduk(produk: Omit<Produk, 'id' | 'dibuat_pada'>): Promise<number> {
  const pool = getPool();
  const [result] = await pool.execute<ResultSetHeader>(
    'INSERT INTO produk SET ?',
    [produk]
  );
  return result.insertId;
}

// ── INSERT batch — sisipkan banyak baris sekaligus
async function buatBanyakProduk(
  produkList: Array<{ nama: string; harga: number; stok: number; kategoriId: number }>
): Promise<number> {
  if (produkList.length === 0) return 0;
  const pool = getPool();

  const values = produkList.map(p => [p.nama, p.harga, p.stok, p.kategoriId]);
  const [result] = await pool.query<ResultSetHeader>(
    'INSERT INTO produk (nama, harga, stok, kategori_id) VALUES ?',
    [values]
  );
  return result.affectedRows;
}
sequenceDiagram
    participant App
    participant Pool
    participant MySQL

    App->>Pool: execute(INSERT, [params])
    Pool->>MySQL: prepared statement
    MySQL-->>Pool: ResultSetHeader
    Pool-->>App: [ResultSetHeader, fields]
    App->>App: result.insertId
    App->>App: result.affectedRows

Operasi UPDATE dan DELETE #

Operasi modifikasi data mengembalikan ResultSetHeader yang mengandung informasi tentang berapa baris yang terpengaruh.

import { ResultSetHeader } from 'mysql2';
import { getPool } from './db/connection';

// ── UPDATE tunggal berdasarkan ID
async function updateUser(
  id: number,
  data: Partial<Pick<User, 'nama' | 'email' | 'role'>>
): Promise<boolean> {
  const pool = getPool();

  // Bangun SET clause secara dinamis dari objek
  const fields = Object.keys(data) as Array<keyof typeof data>;
  if (fields.length === 0) return false;

  const setClauses = fields.map(f => `${f} = ?`).join(', ');
  const values = fields.map(f => data[f]);

  const [result] = await pool.execute<ResultSetHeader>(
    `UPDATE users SET ${setClauses}, diperbarui_pada = NOW() WHERE id = ?`,
    [...values, id]
  );
  return result.affectedRows > 0;
}

// ── UPDATE dengan kondisi ganda
async function nonaktifkanUserLama(hariTidakAktif: number): Promise<number> {
  const pool = getPool();
  const [result] = await pool.execute<ResultSetHeader>(
    `UPDATE users
     SET aktif = false, diperbarui_pada = NOW()
     WHERE aktif = true
       AND diperbarui_pada < DATE_SUB(NOW(), INTERVAL ? DAY)`,
    [hariTidakAktif]
  );
  return result.affectedRows;
}

// ── DELETE — hard delete
async function hapusUser(id: number): Promise<boolean> {
  const pool = getPool();
  const [result] = await pool.execute<ResultSetHeader>(
    'DELETE FROM users WHERE id = ?',
    [id]
  );
  return result.affectedRows > 0;
}

// ── Soft delete — lebih aman untuk data produksi
async function softDeleteUser(id: number): Promise<boolean> {
  const pool = getPool();
  const [result] = await pool.execute<ResultSetHeader>(
    'UPDATE users SET aktif = false, dihapus_pada = NOW() WHERE id = ?',
    [id]
  );
  return result.affectedRows > 0;
}
Selalu cek affectedRows setelah UPDATE atau DELETE. Nilai 0 tidak selalu berarti error — bisa jadi record tidak ditemukan atau kondisi WHERE tidak cocok. Bedakan antara “record tidak ada” dan “error database” dengan memeriksa affectedRows sebelum mengembalikan respons ke client.

Transaction #

Transaction memastikan serangkaian operasi database dieksekusi secara atomik — semua berhasil atau semua dibatalkan. Ini krusial untuk operasi yang melibatkan beberapa tabel sekaligus.

import { PoolConnection } from 'mysql2/promise';
import { getPool } from './db/connection';

interface ItemPesanan {
  produkId: number;
  jumlah: number;
  hargaSatuan: number;
}

async function buatPesanan(
  userId: number,
  items: ItemPesanan[]
): Promise<number> {
  const pool = getPool();
  const conn: PoolConnection = await pool.getConnection();

  try {
    await conn.beginTransaction();

    // 1. Buat record pesanan
    const totalHarga = items.reduce(
      (sum, item) => sum + item.hargaSatuan * item.jumlah, 0
    );

    const [hasilPesanan] = await conn.execute<ResultSetHeader>(
      'INSERT INTO pesanan (user_id, total_harga, status) VALUES (?, ?, ?)',
      [userId, totalHarga, 'pending']
    );
    const pesananId = hasilPesanan.insertId;

    // 2. Sisipkan item-item pesanan
    for (const item of items) {
      await conn.execute(
        'INSERT INTO pesanan_item (pesanan_id, produk_id, jumlah, harga_satuan) VALUES (?, ?, ?, ?)',
        [pesananId, item.produkId, item.jumlah, item.hargaSatuan]
      );

      // 3. Kurangi stok produk
      const [hasilUpdate] = await conn.execute<ResultSetHeader>(
        'UPDATE produk SET stok = stok - ? WHERE id = ? AND stok >= ?',
        [item.jumlah, item.produkId, item.jumlah]
      );

      // Jika stok tidak cukup, rollback seluruh transaksi
      if (hasilUpdate.affectedRows === 0) {
        await conn.rollback();
        throw new Error(`Stok tidak cukup untuk produk ID ${item.produkId}`);
      }
    }

    await conn.commit();
    return pesananId;

  } catch (error) {
    await conn.rollback();
    throw error;  // re-throw agar caller bisa handle
  } finally {
    conn.release();  // WAJIB: kembalikan koneksi ke pool
  }
}
flowchart TD
    A[Mulai transaksi\nconn.beginTransaction] --> B[INSERT pesanan]
    B --> C[Loop items]
    C --> D[INSERT pesanan_item]
    D --> E[UPDATE stok produk]
    E --> F{affectedRows > 0?}
    F -- Tidak --> G[conn.rollback\nThrow Error]
    F -- Ya --> H{Masih ada\nitem?}
    H -- Ya --> C
    H -- Tidak --> I[conn.commit]
    G --> J[conn.release]
    I --> J
    J --> K[Selesai]

Pola helper untuk menyederhanakan penggunaan transaction:

// Helper generik untuk transaction — mengurangi boilerplate
async function withTransaction<T>(
  callback: (conn: PoolConnection) => Promise<T>
): Promise<T> {
  const pool = getPool();
  const conn = await pool.getConnection();
  try {
    await conn.beginTransaction();
    const result = await callback(conn);
    await conn.commit();
    return result;
  } catch (error) {
    await conn.rollback();
    throw error;
  } finally {
    conn.release();
  }
}

// Penggunaan — jauh lebih bersih
const pesananId = await withTransaction(async (conn) => {
  const [r1] = await conn.execute<ResultSetHeader>(
    'INSERT INTO pesanan (user_id, total_harga) VALUES (?, ?)',
    [userId, total]
  );
  await conn.execute(
    'INSERT INTO pesanan_item (pesanan_id, produk_id) VALUES (?, ?)',
    [r1.insertId, produkId]
  );
  return r1.insertId;
});

Error Handling #

Operasi database bisa gagal dengan berbagai alasan. Error handling yang tepat membantu membedakan antara error yang bisa di-recover dan yang tidak.

import { QueryError } from 'mysql2';

// Kode error MySQL yang paling umum
const MYSQL_ERRORS = {
  ER_DUP_ENTRY: 1062,          // UNIQUE constraint violation
  ER_NO_REFERENCED_ROW_2: 1452, // Foreign key violation (INSERT)
  ER_ROW_IS_REFERENCED_2: 1451, // Foreign key violation (DELETE)
  ER_DATA_TOO_LONG: 1406,       // Data terlalu panjang untuk kolom
  ER_BAD_NULL_ERROR: 1048,      // NULL pada kolom NOT NULL
  ER_LOCK_DEADLOCK: 1213,       // Deadlock
  ER_LOCK_WAIT_TIMEOUT: 1205,   // Lock wait timeout
} as const;

// Custom error classes untuk domain yang lebih jelas
class DatabaseError extends Error {
  constructor(
    message: string,
    public readonly code?: number,
    public readonly sqlState?: string
  ) {
    super(message);
    this.name = 'DatabaseError';
  }
}

class DuplicateEntryError extends DatabaseError {
  constructor(field: string) {
    super(`Duplikat: nilai pada field '${field}' sudah digunakan`);
    this.name = 'DuplicateEntryError';
  }
}

class ForeignKeyError extends DatabaseError {
  constructor(message: string) {
    super(message);
    this.name = 'ForeignKeyError';
  }
}

// Fungsi helper untuk memetakan MySQL error ke domain error
function tanganiMysqlError(error: unknown): never {
  if (error instanceof Error && 'errno' in error) {
    const mysqlError = error as QueryError;
    switch (mysqlError.errno) {
      case MYSQL_ERRORS.ER_DUP_ENTRY: {
        // Extract field name dari pesan error MySQL
        const match = mysqlError.message.match(/key '(.+?)'/);
        throw new DuplicateEntryError(match?.[1] ?? 'unknown');
      }
      case MYSQL_ERRORS.ER_NO_REFERENCED_ROW_2:
        throw new ForeignKeyError('Referensi ke record yang tidak ada');
      case MYSQL_ERRORS.ER_ROW_IS_REFERENCED_2:
        throw new ForeignKeyError('Record tidak bisa dihapus karena direferensikan');
      case MYSQL_ERRORS.ER_LOCK_DEADLOCK:
        throw new DatabaseError('Deadlock terdeteksi, coba ulang transaksi', mysqlError.errno);
    }
  }
  throw new DatabaseError(`Error database tidak dikenal: ${String(error)}`);
}

// Penggunaan dalam repository
async function daftarUser(input: InputUser): Promise<number> {
  try {
    const pool = getPool();
    const [result] = await pool.execute<ResultSetHeader>(
      'INSERT INTO users (nama, email, password_hash) VALUES (?, ?, ?)',
      [input.nama, input.email, input.passwordHash]
    );
    return result.insertId;
  } catch (error) {
    tanganiMysqlError(error);
  }
}

Repository Pattern #

Membungkus semua akses database dalam class Repository memberikan abstraksi yang memudahkan testing, mengurangi duplikasi query, dan memisahkan logika bisnis dari detail database.

// src/repositories/user.repository.ts
import { ResultSetHeader } from 'mysql2';
import { getPool } from '../db/connection';

export interface BuatUserInput {
  nama: string;
  email: string;
  passwordHash: string;
  role?: 'admin' | 'user';
}

export interface FilterUser {
  role?: string;
  aktif?: boolean;
  limit?: number;
  offset?: number;
}

export class UserRepository {
  async findById(id: number): Promise<User | null> {
    const [rows] = await getPool().execute<User[]>(
      'SELECT * FROM users WHERE id = ? AND aktif = true',
      [id]
    );
    return rows[0] ?? null;
  }

  async findByEmail(email: string): Promise<User | null> {
    const [rows] = await getPool().execute<User[]>(
      'SELECT * FROM users WHERE email = ?',
      [email]
    );
    return rows[0] ?? null;
  }

  async findAll(filter: FilterUser = {}): Promise<User[]> {
    const { role, aktif = true, limit = 20, offset = 0 } = filter;
    const conditions: string[] = ['1 = 1'];
    const params: unknown[] = [];

    if (role !== undefined) {
      conditions.push('role = ?');
      params.push(role);
    }
    conditions.push('aktif = ?');
    params.push(aktif);
    params.push(limit, offset);

    const [rows] = await getPool().execute<User[]>(
      `SELECT id, nama, email, role, dibuat_pada
       FROM users
       WHERE ${conditions.join(' AND ')}
       ORDER BY dibuat_pada DESC
       LIMIT ? OFFSET ?`,
      params
    );
    return rows;
  }

  async create(input: BuatUserInput): Promise<number> {
    const [result] = await getPool().execute<ResultSetHeader>(
      'INSERT INTO users (nama, email, password_hash, role) VALUES (?, ?, ?, ?)',
      [input.nama, input.email, input.passwordHash, input.role ?? 'user']
    );
    return result.insertId;
  }

  async update(id: number, data: Partial<Pick<User, 'nama' | 'email'>>): Promise<boolean> {
    const entries = Object.entries(data).filter(([, v]) => v !== undefined);
    if (entries.length === 0) return false;

    const setClauses = entries.map(([k]) => `${k} = ?`).join(', ');
    const values = entries.map(([, v]) => v);

    const [result] = await getPool().execute<ResultSetHeader>(
      `UPDATE users SET ${setClauses}, diperbarui_pada = NOW() WHERE id = ?`,
      [...values, id]
    );
    return result.affectedRows > 0;
  }

  async delete(id: number): Promise<boolean> {
    const [result] = await getPool().execute<ResultSetHeader>(
      'UPDATE users SET aktif = false WHERE id = ?',
      [id]
    );
    return result.affectedRows > 0;
  }

  async count(aktif = true): Promise<number> {
    interface CountRow extends RowDataPacket { total: number }
    const [rows] = await getPool().execute<CountRow[]>(
      'SELECT COUNT(*) AS total FROM users WHERE aktif = ?',
      [aktif]
    );
    return rows[0].total;
  }
}

// Gunakan sebagai singleton
export const userRepo = new UserRepository();

Paginasi dan Sorting yang Aman #

Paginasi dan sorting dinamis memerlukan perhatian ekstra karena nama kolom tidak bisa diparameterkan — harus divalidasi manual untuk mencegah SQL injection.

type SortOrder = 'ASC' | 'DESC';
type UserSortableField = 'nama' | 'email' | 'dibuat_pada';

// Whitelist kolom yang boleh di-sort
const ALLOWED_SORT_FIELDS: Set<UserSortableField> = new Set([
  'nama', 'email', 'dibuat_pada'
]);

interface PaginasiParams {
  halaman: number;
  perHalaman: number;
  sortBy?: UserSortableField;
  sortOrder?: SortOrder;
}

interface PaginasiResult<T> {
  data: T[];
  total: number;
  halaman: number;
  perHalaman: number;
  totalHalaman: number;
}

async function getUserPaginasi(
  params: PaginasiParams
): Promise<PaginasiResult<User>> {
  const {
    halaman = 1,
    perHalaman = 20,
    sortBy = 'dibuat_pada',
    sortOrder = 'DESC'
  } = params;

  // WAJIB: validasi nama kolom dari whitelist
  const safeSort = ALLOWED_SORT_FIELDS.has(sortBy) ? sortBy : 'dibuat_pada';
  // WAJIB: validasi sort order
  const safeOrder: SortOrder = sortOrder === 'ASC' ? 'ASC' : 'DESC';

  const offset = (halaman - 1) * perHalaman;
  const pool = getPool();

  const [[{ total }], [rows]] = await Promise.all([
    pool.execute<Array<{ total: number } & RowDataPacket>>(
      'SELECT COUNT(*) AS total FROM users WHERE aktif = true'
    ),
    pool.execute<User[]>(
      // safeSort sudah divalidasi dari whitelist — aman diinterpolasi
      `SELECT id, nama, email, role, dibuat_pada
       FROM users
       WHERE aktif = true
       ORDER BY ${safeSort} ${safeOrder}
       LIMIT ? OFFSET ?`,
      [perHalaman, offset]
    )
  ]);

  return {
    data: rows,
    total,
    halaman,
    perHalaman,
    totalHalaman: Math.ceil(total / perHalaman),
  };
}

Kapan Beralih ke Pendekatan Lain #

Tetap gunakan mysql2 langsung jika:
  ✓ Query kompleks yang butuh kontrol penuh atas SQL
  ✓ Performa kritis dengan query yang sudah dioptimasi
  ✓ Aplikasi sederhana tanpa banyak model/tabel
  ✓ Tim sudah familiar dengan SQL dan tidak butuh abstraksi tambahan
  ✓ Migrasi database dikelola oleh tool terpisah (Flyway, Liquibase)

Pertimbangkan ORM / Query Builder jika:
  ✗ Banyak model dengan relasi kompleks — pertimbangkan TypeORM atau Prisma
  ✗ Butuh migrasi otomatis dari model — Prisma atau TypeORM migrations
  ✗ Multi-database (MySQL + PostgreSQL) — TypeORM atau Knex.js
  ✗ Rapid prototyping dengan sedikit SQL — Prisma sangat produktif
  ✗ Tim kurang familiar SQL — ORM membantu tapi bisa menyembunyikan masalah performa
LibraryPendekatanCocok Untuk
mysql2Query langsungKontrol penuh, performa tinggi
Knex.jsQuery builderSQL dinamis dengan type safety
TypeORMORM + decoratorEnterprise, banyak relasi
PrismaORM + schemaRapid dev, type safety terbaik
DrizzleORM ringanTypeScript-first, minimalis

Ringkasan #

  • Selalu gunakan mysql2/promise, bukan versi callback — API Promise jauh lebih ergonomis dengan async/await dan tipe TypeScript lebih akurat.
  • Connection pool untuk aplikasi server — jangan buat koneksi baru per request; gunakan createPool dengan connectionLimit yang sesuai beban aplikasi.
  • execute() untuk query berparameter, query() untuk query statisexecute() menggunakan server-side prepared statements yang lebih aman dan efisien untuk query berulang.
  • Parameterized query tanpa kompromi — jangan pernah interpolasi input user ke dalam SQL string; selalu gunakan ? sebagai placeholder.
  • Nama kolom tidak bisa diparameterkan — untuk sorting/filtering dinamis, validasi nama kolom dari whitelist sebelum interpolasi ke query.
  • interface extends RowDataPacket untuk tipe hasil query — tanpa ini, TypeScript tidak bisa mencocokkan return type dari execute<T[]>.
  • conn.release() di blok finally untuk transaction — koneksi yang tidak di-release menyebabkan pool exhausted dan seluruh aplikasi hang.
  • Gunakan helper withTransaction untuk mengurangi boilerplate try/catch/rollback/release yang berulang di setiap transaksi.
  • Cek affectedRows setelah UPDATE/DELETE — nilai 0 bisa berarti record tidak ditemukan, bukan selalu error; bedakan keduanya dalam respons API.
  • Repository Pattern memisahkan concern — semua SQL ada di repository, service layer tidak tahu detail database, dan testing bisa dilakukan dengan mock repository.

← Sebelumnya: SQL   Berikutnya: MSSQL →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact