Oracle #

Oracle Database adalah salah satu database enterprise paling matang dan banyak digunakan di perusahaan besar, lembaga keuangan, dan instansi pemerintahan. Mengintegrasikannya dengan TypeScript menggunakan library oracledb dari Oracle sendiri memberi akses ke fitur-fitur canggih seperti REF CURSOR, PL/SQL anonymous blocks, LOB (Large Objects), dan RETURNING INTO — semuanya dengan dukungan TypeScript yang cukup baik. Ada beberapa karakteristik Oracle yang perlu kamu pahami sejak awal: bind parameters menggunakan prefix : (bukan ? atau @), nama tabel dan kolom secara default case-insensitive dan disimpan dalam huruf kapital, dan library ini memerlukan Oracle Client atau Instant Client yang terinstal di sistem. Memahami keunikan-keunikan ini akan menghindarkan kamu dari kebingungan yang sering dialami developer saat pertama kali bekerja dengan Oracle.

Instalasi dan Setup #

oracledb memerlukan Oracle Instant Client yang terinstal di sistem operasi, selain paket npm-nya. Ini berbeda dari mysql2 dan mssql yang merupakan pure JavaScript.

# Instalasi paket npm
npm install oracledb

# Type definitions — perlu instalasi terpisah
npm install --save-dev @types/oracledb

Instalasi Oracle Instant Client #

# macOS (via Homebrew)
brew install instantclient-basic

# Ubuntu/Debian
# Download dari: https://www.oracle.com/database/technologies/instant-client/downloads.html
# Ekstrak ke /opt/oracle/instantclient_21_x
sudo apt-get install libaio1
export LD_LIBRARY_PATH=/opt/oracle/instantclient_21_x:$LD_LIBRARY_PATH

# Windows — tambahkan folder Instant Client ke PATH
// src/db/connection.ts — inisialisasi oracledb
import oracledb, {
  Connection,
  Pool,
  BindParameters,
  ExecuteOptions,
} from 'oracledb';

// Konfigurasi global — panggil sekali saat startup
oracledb.outFormat = oracledb.OUT_FORMAT_OBJECT;  // hasil sebagai objek, bukan array
oracledb.autoCommit = false;                       // selalu eksplisit commit
oracledb.fetchArraySize = 100;                     // jumlah baris per fetch dari server
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

Struktur project yang direkomendasikan:

src/
  ├── db/
  │   ├── connection.ts      -- pool singleton dan konfigurasi global
  │   └── types.ts           -- tipe Oracle custom dan helper
  ├── models/
  │   ├── user.model.ts
  │   └── produk.model.ts
  ├── repositories/
  │   ├── base.repository.ts
  │   └── user.repository.ts
  └── index.ts

Konfigurasi Connection Pool #

Oracle menggunakan createPool() untuk membuat pool koneksi. Pool Oracle punya fitur unik bernama sessionCallback yang memungkinkan inisialisasi sesi saat koneksi pertama kali diambil dari pool.

import oracledb, { Pool, Connection, PoolAttributes } from 'oracledb';

const poolConfig: PoolAttributes = {
  user: process.env.DB_USER ?? 'system',
  password: process.env.DB_PASSWORD ?? '',
  connectString: process.env.DB_CONNECT_STRING ?? 'localhost:1521/XEPDB1',
  // Format connectString:
  // - Easy Connect: "host:port/service_name"
  // - TNS alias:    "MYDB" (butuh tnsnames.ora)
  // - Full:         "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=host)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=svc)))"
  poolMin: 2,            // koneksi minimum yang selalu hidup
  poolMax: 10,           // koneksi maksimum
  poolIncrement: 1,      // tambah koneksi sebanyak ini saat butuh
  poolTimeout: 60,       // detik sebelum koneksi idle dihapus dari pool
  poolPingInterval: 60,  // cek koneksi idle setiap N detik
  sessionCallback: initSession,  // panggil saat koneksi baru dibuat
};

// Inisialisasi sesi — dijalankan sekali per koneksi baru di pool
async function initSession(
  connection: Connection,
  requestedTag: string,
  callback: (error?: Error) => void
): Promise<void> {
  try {
    // Set timezone, format tanggal, dan parameter sesi lainnya
    await connection.execute(
      `ALTER SESSION SET
         NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'
         NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF'
         TIME_ZONE = 'Asia/Jakarta'`
    );
    callback();
  } catch (err) {
    callback(err instanceof Error ? err : new Error(String(err)));
  }
}

let poolInstance: Pool | null = null;

export async function getPool(): Promise<Pool> {
  if (!poolInstance) {
    poolInstance = await oracledb.createPool(poolConfig);
  }
  return poolInstance;
}

export async function getConnection(): Promise<Connection> {
  const pool = await getPool();
  return pool.getConnection();
}

export async function closePool(): Promise<void> {
  if (poolInstance) {
    await poolInstance.close(10);  // tunggu max 10 detik untuk koneksi aktif selesai
    poolInstance = null;
  }
}

process.on('SIGTERM', closePool);
process.on('SIGINT', closePool);
flowchart TD
    A[oracledb.createPool] --> B[Pool siap\nmin: 2 koneksi]
    B --> C[Request masuk]
    C --> D[pool.getConnection]
    D --> E{Ada koneksi\nidle di pool?}
    E -- Ya --> F[Ambil koneksi\nidle]
    E -- Tidak --> G{Jumlah < poolMax?}
    G -- Ya --> H[Buat koneksi baru\njalankan sessionCallback]
    G -- Tidak --> I[Tunggu koneksi\ntersedia]
    F --> J[Eksekusi query]
    H --> J
    I --> F
    J --> K[connection.close\nkembalikan ke pool]
    K --> B
Berbeda dari mysql2 dan mssql, oracledb memerlukan connection.close() — bukan connection.release() — untuk mengembalikan koneksi ke pool. Memanggil close() pada koneksi yang berasal dari pool secara otomatis mengembalikannya ke pool, bukan benar-benar menutupnya. Lupa memanggil close() akan menghabiskan slot pool secara permanen.

Mendefinisikan Tipe untuk Row Database #

Saat menggunakan oracledb.OUT_FORMAT_OBJECT, hasil query dikembalikan sebagai array objek. Nama kolom yang dikembalikan adalah huruf kapital semua sesuai konvensi Oracle, kecuali jika kamu menggunakan alias kolom dengan huruf kecil dalam query.

// Tipe untuk tabel USERS (nama kolom Oracle selalu kapital)
interface UserRow {
  ID: number;
  NAMA: string;
  EMAIL: string;
  PASSWORD_HASH: string;
  ROLE: string;
  AKTIF: number;         // Oracle tidak punya BOOLEAN native — gunakan NUMBER(1)
  DIBUAT_PADA: Date;
  DIPERBARUI_PADA: Date | null;
}

// Tipe yang lebih friendly untuk digunakan di aplikasi
interface User {
  id: number;
  nama: string;
  email: string;
  passwordHash: string;
  role: 'admin' | 'user' | 'moderator';
  aktif: boolean;
  dibuatPada: Date;
  diperbarui_pada: Date | null;
}

// Fungsi mapper dari Oracle row ke domain type
function mapRowToUser(row: UserRow): User {
  return {
    id: row.ID,
    nama: row.NAMA,
    email: row.EMAIL,
    passwordHash: row.PASSWORD_HASH,
    role: row.ROLE as User['role'],
    aktif: row.AKTIF === 1,
    dibuatPada: row.DIBUAT_PADA,
    diperbarui_pada: row.DIPERBARUI_PADA,
  };
}

Cara alternatif yang lebih ringkas: gunakan alias kolom huruf kecil langsung di query SQL.

// Dengan alias lowercase di SQL — hasilnya langsung camelCase-friendly
interface UserFromQuery {
  id: number;
  nama: string;
  email: string;
  aktif: number;
  dibuat_pada: Date;
}

// Query dengan alias kolom explicit
const SQL_SELECT_USER = `
  SELECT
    u.id          AS "id",
    u.nama        AS "nama",
    u.email       AS "email",
    u.aktif       AS "aktif",
    u.dibuat_pada AS "dibuat_pada"
  FROM users u
  WHERE u.id = :id
`;
// Kolom dalam double-quote di Oracle menjadi case-sensitive

Pemetaan tipe data Oracle ke TypeScript:

Oracle TypeTypeScript TypeCatatan
NUMBER, INTEGERnumberHati-hati untuk BIGINT
NUMBER(1)number (0/1)Oracle tidak punya BOOLEAN native
VARCHAR2(n)stringMaksimum 4000 karakter
NVARCHAR2(n)stringUnicode, maksimum 2000 karakter
CLOBstringText panjang, perlu fetch khusus
DATEDateTermasuk komponen waktu
TIMESTAMPDatePresisi hingga nanosecond
BLOBBufferData biner

Query Dasar — SELECT #

Oracle menggunakan :namaParam sebagai placeholder bind parameter. Berbeda dari MySQL dan MSSQL, kamu bisa menggunakan bind by position (:1, :2) atau bind by name (:namaParam).

import oracledb from 'oracledb';
import { getConnection } from './db/connection';

// ── SELECT sederhana
async function semuaUser(): Promise<User[]> {
  const conn = await getConnection();
  try {
    const result = await conn.execute<UserRow>(
      `SELECT id, nama, email, role, aktif, dibuat_pada
       FROM users
       WHERE aktif = 1
       ORDER BY dibuat_pada DESC`
    );
    return (result.rows ?? []).map(mapRowToUser);
  } finally {
    await conn.close();  // WAJIB: selalu close di finally
  }
}

// ── SELECT dengan bind parameter — gunakan :namaParam
async function cariUserById(id: number): Promise<User | null> {
  const conn = await getConnection();
  try {
    const result = await conn.execute<UserRow>(
      'SELECT * FROM users WHERE id = :id AND aktif = 1',
      { id }  // bind by name: { id: value }
    );
    const row = result.rows?.[0];
    return row ? mapRowToUser(row) : null;
  } finally {
    await conn.close();
  }
}

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

// ── SELECT dengan LIKE — Oracle menggunakan || untuk concatenation
async function cariProdukByNama(keyword: string): Promise<Produk[]> {
  const conn = await getConnection();
  try {
    // Escape karakter khusus LIKE Oracle: %, _, \
    const safeKeyword = keyword.replace(/[%_\\]/g, '\\$&');
    const result = await conn.execute<ProdukRow>(
      `SELECT * FROM produk
       WHERE LOWER(nama) LIKE LOWER(:keyword) ESCAPE '\'
       FETCH FIRST 20 ROWS ONLY`,
      { keyword: `%${safeKeyword}%` }
    );
    return (result.rows ?? []).map(mapRowToProduk);
  } finally {
    await conn.close();
  }
}

// ── SELECT dengan IN — Oracle tidak bisa bind array langsung
async function produkByIds(ids: number[]): Promise<Produk[]> {
  if (ids.length === 0) return [];
  const conn = await getConnection();
  try {
    // Buat bind variables dinamis: :id0, :id1, :id2, ...
    const binds: Record<string, number> = {};
    const placeholders = ids.map((id, i) => {
      binds[`id${i}`] = id;
      return `:id${i}`;
    });

    const result = await conn.execute<ProdukRow>(
      `SELECT * FROM produk WHERE id IN (${placeholders.join(', ')})`,
      binds
    );
    return (result.rows ?? []).map(mapRowToProduk);
  } finally {
    await conn.close();
  }
}
// ANTI-PATTERN: string interpolasi — rentan SQL injection
async function cariUserTidakAman(email: string): Promise<UserRow[]> {
  const conn = await getConnection();
  try {
    const result = await conn.execute<UserRow>(
      `SELECT * FROM users WHERE email = '${email}'`  // JANGAN!
    );
    return result.rows ?? [];
  } finally {
    await conn.close();
  }
}

// BENAR: selalu gunakan bind parameter
async function cariUserAman(email: string): Promise<User | null> {
  const conn = await getConnection();
  try {
    const result = await conn.execute<UserRow>(
      'SELECT * FROM users WHERE email = :email',
      { email }  // nilai diproses terpisah oleh Oracle — aman
    );
    const row = result.rows?.[0];
    return row ? mapRowToUser(row) : null;
  } finally {
    await conn.close();
  }
}

Operasi INSERT #

Oracle menggunakan klausa RETURNING INTO untuk mendapatkan nilai yang baru diinsert — ekuivalen dengan OUTPUT INSERTED di SQL Server.

import oracledb from 'oracledb';
import { getConnection } from './db/connection';

interface InputUser {
  nama: string;
  email: string;
  passwordHash: string;
  role?: string;
}

// ── INSERT dengan RETURNING INTO untuk mendapat ID
async function buatUser(input: InputUser): Promise<number> {
  const conn = await getConnection();
  try {
    const result = await conn.execute(
      `INSERT INTO users (id, nama, email, password_hash, role, aktif, dibuat_pada)
       VALUES (users_seq.NEXTVAL, :nama, :email, :passwordHash, :role, 1, SYSDATE)
       RETURNING id INTO :newId`,
      {
        nama: input.nama,
        email: input.email,
        passwordHash: input.passwordHash,
        role: input.role ?? 'user',
        newId: { dir: oracledb.BIND_OUT, type: oracledb.NUMBER },
      }
    );

    await conn.commit();
    // RETURNING INTO dikembalikan dalam outBinds
    const outBinds = result.outBinds as { newId: number[] };
    return outBinds.newId[0];
  } catch (error) {
    await conn.rollback();
    throw error;
  } finally {
    await conn.close();
  }
}

// ── INSERT batch dengan executemany — jauh lebih efisien dari loop
async function buatBanyakProduk(
  produkList: Array<{ nama: string; harga: number; stok: number; kategoriId: number }>
): Promise<number> {
  if (produkList.length === 0) return 0;

  const conn = await getConnection();
  try {
    const binds = produkList.map(p => ({
      nama: p.nama,
      harga: p.harga,
      stok: p.stok,
      kategoriId: p.kategoriId,
    }));

    const result = await conn.executeMany(
      `INSERT INTO produk (id, nama, harga, stok, kategori_id, dibuat_pada)
       VALUES (produk_seq.NEXTVAL, :nama, :harga, :stok, :kategoriId, SYSDATE)`,
      binds,
      { autoCommit: true }  // commit otomatis setelah semua baris diinsert
    );

    return result.rowsAffected ?? 0;
  } catch (error) {
    await conn.rollback();
    throw error;
  } finally {
    await conn.close();
  }
}
sequenceDiagram
    participant App
    participant Oracle
    participant Sequence

    App->>Oracle: INSERT ... VALUES (seq.NEXTVAL, :params)\nRETURNING id INTO :newId
    Oracle->>Sequence: seq.NEXTVAL
    Sequence-->>Oracle: 42
    Oracle->>Oracle: Simpan row dengan id=42
    Oracle-->>App: outBinds.newId = [42]
    App->>Oracle: conn.commit()
    Oracle-->>App: OK
Oracle tidak punya auto-increment kolom seperti MySQL (kecuali kolom GENERATED ALWAYS AS IDENTITY di Oracle 12c+). Cara tradisional menggunakan Sequence (CREATE SEQUENCE nama_seq) yang dipanggil dengan nama_seq.NEXTVAL. Pastikan sequence sudah dibuat di database sebelum menjalankan INSERT, atau gunakan GENERATED ALWAYS AS IDENTITY jika Oracle versi 12c ke atas.

Operasi UPDATE dan DELETE #

import oracledb from 'oracledb';
import { getConnection } from './db/connection';

// ── UPDATE dengan RETURNING INTO
async function updateStokProduk(
  id: number,
  deltaStok: number
): Promise<{ stokBaru: number } | null> {
  const conn = await getConnection();
  try {
    const result = await conn.execute(
      `UPDATE produk
       SET stok = stok + :delta
       WHERE id = :id AND (stok + :delta2) >= 0
       RETURNING stok INTO :stokBaru`,
      {
        id,
        delta: deltaStok,
        delta2: deltaStok,  // Oracle perlu bind terpisah untuk nilai yang sama
        stokBaru: { dir: oracledb.BIND_OUT, type: oracledb.NUMBER },
      }
    );

    await conn.commit();
    const out = result.outBinds as { stokBaru: number[] };
    if (!out.stokBaru || out.stokBaru.length === 0) return null;
    return { stokBaru: out.stokBaru[0] };
  } catch (error) {
    await conn.rollback();
    throw error;
  } finally {
    await conn.close();
  }
}

// ── UPDATE field tertentu
async function updateUser(
  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;

  // Whitelist — WAJIB untuk nama kolom yang diinterpolasi
  const allowedColumns: Record<string, string> = {
    nama: 'nama',
    email: 'email',
  };

  const setClauses: string[] = [];
  const binds: Record<string, unknown> = { id };

  for (const [key, value] of entries) {
    const col = allowedColumns[key];
    if (!col) continue;
    setClauses.push(`${col} = :${key}`);
    binds[key] = value;
  }

  if (setClauses.length === 0) return false;

  const conn = await getConnection();
  try {
    const result = await conn.execute(
      `UPDATE users
       SET ${setClauses.join(', ')}, diperbarui_pada = SYSDATE
       WHERE id = :id`,
      binds
    );
    await conn.commit();
    return (result.rowsAffected ?? 0) > 0;
  } catch (error) {
    await conn.rollback();
    throw error;
  } finally {
    await conn.close();
  }
}

// ── DELETE
async function hapusUser(id: number): Promise<boolean> {
  const conn = await getConnection();
  try {
    const result = await conn.execute(
      'DELETE FROM users WHERE id = :id',
      { id }
    );
    await conn.commit();
    return (result.rowsAffected ?? 0) > 0;
  } catch (error) {
    await conn.rollback();
    throw error;
  } finally {
    await conn.close();
  }
}

Transaction #

Di Oracle, setiap sesi database secara implisit memulai transaksi saat query pertama dieksekusi. Commit atau rollback mengakhiri transaksi dan memulai yang baru. Tidak ada perintah BEGIN TRANSACTION yang eksplisit seperti di MySQL atau SQL Server.

import oracledb from 'oracledb';
import { getConnection } from './db/connection';

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

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

  try {
    // Oracle: transaksi dimulai otomatis saat query pertama
    const totalHarga = items.reduce(
      (sum, item) => sum + item.hargaSatuan * item.jumlah, 0
    );

    // 1. Insert pesanan
    const r1 = await conn.execute(
      `INSERT INTO pesanan (id, user_id, total_harga, status, dibuat_pada)
       VALUES (pesanan_seq.NEXTVAL, :userId, :total, 'pending', SYSDATE)
       RETURNING id INTO :newId`,
      {
        userId,
        total: totalHarga,
        newId: { dir: oracledb.BIND_OUT, type: oracledb.NUMBER },
      }
    );

    const out1 = r1.outBinds as { newId: number[] };
    const pesananId = out1.newId[0];

    // 2. Proses setiap item
    for (const item of items) {
      await conn.execute(
        `INSERT INTO pesanan_item
           (id, pesanan_id, produk_id, jumlah, harga_satuan)
         VALUES (pesanan_item_seq.NEXTVAL, :pesananId, :produkId, :jumlah, :harga)`,
        {
          pesananId,
          produkId: item.produkId,
          jumlah: item.jumlah,
          harga: item.hargaSatuan,
        }
      );

      // Kurangi stok — gagal jika stok tidak cukup
      const rStok = await conn.execute(
        `UPDATE produk
         SET stok = stok - :jumlah
         WHERE id = :produkId AND stok >= :jumlah2`,
        { jumlah: item.jumlah, produkId: item.produkId, jumlah2: item.jumlah }
      );

      if ((rStok.rowsAffected ?? 0) === 0) {
        await conn.rollback();
        throw new Error(`Stok tidak cukup untuk produk ID ${item.produkId}`);
      }
    }

    // Commit hanya jika semua langkah berhasil
    await conn.commit();
    return pesananId;

  } catch (error) {
    try { await conn.rollback(); } catch { /* abaikan error rollback */ }
    throw error;
  } finally {
    await conn.close();
  }
}
flowchart TD
    A[conn = pool.getConnection] --> B[Transaksi dimulai\notomatis saat query pertama]
    B --> C[INSERT pesanan]
    C --> D[Loop items]
    D --> E[INSERT pesanan_item]
    E --> F[UPDATE stok]
    F --> G{rowsAffected > 0?}
    G -- Tidak --> H[conn.rollback\nThrow Error]
    G -- Ya --> I{Masih ada\nitem?}
    I -- Ya --> D
    I -- Tidak --> J[conn.commit]
    H --> K[conn.close]
    J --> K

Stored Procedure dan PL/SQL #

Oracle sangat erat dengan PL/SQL. oracledb mendukung pemanggilan stored procedure dan PL/SQL anonymous blocks secara native.

import oracledb from 'oracledb';
import { getConnection } from './db/connection';

// ── Memanggil stored procedure
async function spCariUser(email: string): Promise<User | null> {
  const conn = await getConnection();
  try {
    const result = await conn.execute(
      'BEGIN sp_cari_user(:email, :cursor); END;',
      {
        email,
        cursor: { dir: oracledb.BIND_OUT, type: oracledb.CURSOR },
      }
    );

    const out = result.outBinds as { cursor: oracledb.ResultSet<UserRow> };
    const cursor = out.cursor;
    const rows = await cursor.getRows<UserRow>(1);
    await cursor.close();

    return rows[0] ? mapRowToUser(rows[0]) : null;
  } finally {
    await conn.close();
  }
}

// ── Stored procedure dengan OUTPUT parameter
async function spDaftarUser(
  input: InputUser
): Promise<{ userId: number; pesanError: string | null }> {
  const conn = await getConnection();
  try {
    const result = await conn.execute(
      `BEGIN
         sp_daftar_user(
           p_nama         => :nama,
           p_email        => :email,
           p_password     => :password,
           p_user_id      => :userId,
           p_pesan_error  => :pesanError
         );
       END;`,
      {
        nama: input.nama,
        email: input.email,
        password: input.passwordHash,
        userId:     { dir: oracledb.BIND_OUT, type: oracledb.NUMBER },
        pesanError: { dir: oracledb.BIND_OUT, type: oracledb.STRING, maxSize: 500 },
      }
    );

    await conn.commit();
    const out = result.outBinds as { userId: number; pesanError: string | null };
    return { userId: out.userId, pesanError: out.pesanError };
  } catch (error) {
    await conn.rollback();
    throw error;
  } finally {
    await conn.close();
  }
}

// ── PL/SQL anonymous block — untuk logika yang tidak perlu jadi SP permanen
async function prosesDataBatch(batchId: number): Promise<{ berhasil: number; gagal: number }> {
  const conn = await getConnection();
  try {
    const result = await conn.execute(
      `DECLARE
         v_berhasil NUMBER := 0;
         v_gagal    NUMBER := 0;
       BEGIN
         FOR rec IN (SELECT id FROM antrian_proses WHERE batch_id = :batchId)
         LOOP
           BEGIN
             UPDATE produk SET stok = stok + 1 WHERE id = rec.id;
             v_berhasil := v_berhasil + 1;
           EXCEPTION
             WHEN OTHERS THEN
               v_gagal := v_gagal + 1;
           END;
         END LOOP;
         :berhasil := v_berhasil;
         :gagal    := v_gagal;
       END;`,
      {
        batchId,
        berhasil: { dir: oracledb.BIND_OUT, type: oracledb.NUMBER },
        gagal:    { dir: oracledb.BIND_OUT, type: oracledb.NUMBER },
      }
    );

    await conn.commit();
    const out = result.outBinds as { berhasil: number; gagal: number };
    return { berhasil: out.berhasil, gagal: out.gagal };
  } finally {
    await conn.close();
  }
}

Error Handling #

Oracle menggunakan kode error ORA-XXXXX. Memahami kode-kode umum ini dan memetakannya ke domain error yang bermakna adalah kunci untuk pesan error yang informatif.

import oracledb from 'oracledb';

// Kode error Oracle yang paling sering ditemui
const ORA_ERRORS = {
  UNIQUE_VIOLATION: 1,        // ORA-00001: unique constraint violated
  INTEGRITY_VIOLATION: 2291,  // ORA-02291: integrity constraint (FK) violated
  CHILD_EXISTS: 2292,         // ORA-02292: child record found (delete FK)
  NOT_NULL_VIOLATION: 1400,   // ORA-01400: cannot insert NULL
  VALUE_TOO_LARGE: 12899,     // ORA-12899: value too large for column
  DEADLOCK: 60,               // ORA-00060: deadlock detected
  SNAPSHOT_TOO_OLD: 1555,     // ORA-01555: snapshot too old
  TABLE_NOT_EXIST: 942,       // ORA-00942: table or view does not exist
  SEQUENCE_NOT_EXIST: 2289,   // ORA-02289: sequence does not exist
  INVALID_NUMBER: 1722,       // ORA-01722: invalid number
} as const;

class DatabaseError extends Error {
  constructor(message: string, public readonly code?: number) {
    super(message);
    this.name = 'DatabaseError';
  }
}

class UniqueViolationError extends DatabaseError {
  constructor(constraint?: string) {
    super(`Duplikat data${constraint ? ` pada constraint '${constraint}'` : ''}`);
    this.name = 'UniqueViolationError';
  }
}

class ForeignKeyError extends DatabaseError {
  constructor(message = 'Referensi data tidak valid') {
    super(message);
    this.name = 'ForeignKeyError';
  }
}

class DeadlockError extends DatabaseError {
  constructor() {
    super('Deadlock terdeteksi — coba ulangi operasi');
    this.name = 'DeadlockError';
  }
}

function tanganiOracleError(error: unknown): never {
  // oracledb error memiliki properti errorNum
  if (error && typeof error === 'object' && 'errorNum' in error) {
    const oraError = error as { errorNum: number; message: string };
    switch (oraError.errorNum) {
      case ORA_ERRORS.UNIQUE_VIOLATION: {
        // Ekstrak nama constraint dari pesan: ORA-00001: unique constraint (SCHEMA.CONST) violated
        const match = oraError.message.match(/\((.+?)\)/);
        throw new UniqueViolationError(match?.[1]);
      }
      case ORA_ERRORS.INTEGRITY_VIOLATION:
        throw new ForeignKeyError('Record referensi tidak ditemukan');
      case ORA_ERRORS.CHILD_EXISTS:
        throw new ForeignKeyError('Tidak bisa menghapus — masih ada data yang mereferensikan');
      case ORA_ERRORS.NOT_NULL_VIOLATION:
        throw new DatabaseError('Field wajib tidak boleh kosong', oraError.errorNum);
      case ORA_ERRORS.VALUE_TOO_LARGE:
        throw new DatabaseError('Data melebihi panjang maksimum kolom', oraError.errorNum);
      case ORA_ERRORS.DEADLOCK:
        throw new DeadlockError();
    }
  }
  throw new DatabaseError(`Error Oracle: ${String(error)}`);
}

// Retry otomatis untuk deadlock
async function withDeadlockRetry<T>(
  fn: () => Promise<T>,
  maxRetry = 3
): Promise<T> {
  for (let attempt = 1; attempt <= maxRetry; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (error instanceof DeadlockError && attempt < maxRetry) {
        await new Promise(res => setTimeout(res, 200 * attempt));
        continue;
      }
      throw error;
    }
  }
  throw new Error('Unreachable');
}

Paginasi di Oracle #

Oracle menggunakan OFFSET ... FETCH NEXT (tersedia sejak Oracle 12c). Untuk Oracle 11g ke bawah, gunakan ROWNUM atau ROW_NUMBER().

import oracledb from 'oracledb';
import { getConnection } from './db/connection';

type SortField = 'nama' | 'email' | 'dibuat_pada';
const ALLOWED_SORT = new Set<SortField>(['nama', 'email', 'dibuat_pada']);

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

// ── Oracle 12c+ — OFFSET...FETCH
async function getUserPaginasi(
  halaman: number,
  perHalaman: number,
  sortBy: SortField = 'dibuat_pada',
  sortOrder: 'ASC' | 'DESC' = 'DESC'
): Promise<PaginasiResult<User>> {
  const safeSort = ALLOWED_SORT.has(sortBy) ? sortBy : 'dibuat_pada';
  const safeOrder = sortOrder === 'ASC' ? 'ASC' : 'DESC';
  const offset = (halaman - 1) * perHalaman;

  const conn = await getConnection();
  try {
    const [dataResult, countResult] = await Promise.all([
      conn.execute<UserRow>(
        `SELECT id, nama, email, role, aktif, dibuat_pada
         FROM users
         WHERE aktif = 1
         ORDER BY ${safeSort} ${safeOrder}
         OFFSET :offset ROWS FETCH NEXT :perHalaman ROWS ONLY`,
        { offset, perHalaman }
      ),
      conn.execute<{ TOTAL: number }>(
        'SELECT COUNT(*) AS TOTAL FROM users WHERE aktif = 1'
      ),
    ]);

    const total = countResult.rows?.[0]?.TOTAL ?? 0;

    return {
      data: (dataResult.rows ?? []).map(mapRowToUser),
      total,
      halaman,
      perHalaman,
      totalHalaman: Math.ceil(total / perHalaman),
    };
  } finally {
    await conn.close();
  }
}

// ── Oracle 11g dan lebih lama — ROW_NUMBER()
async function getUserPaginasiLegacy(
  halaman: number,
  perHalaman: number
): Promise<User[]> {
  const conn = await getConnection();
  try {
    const rn_start = (halaman - 1) * perHalaman + 1;
    const rn_end = halaman * perHalaman;

    const result = await conn.execute<UserRow>(
      `SELECT * FROM (
         SELECT u.*, ROW_NUMBER() OVER (ORDER BY dibuat_pada DESC) AS rn
         FROM users u
         WHERE aktif = 1
       )
       WHERE rn BETWEEN :rnStart AND :rnEnd`,
      { rnStart: rn_start, rnEnd: rn_end }
    );
    return (result.rows ?? []).map(mapRowToUser);
  } finally {
    await conn.close();
  }
}

Kapan Beralih ke Pendekatan Lain #

Tetap gunakan oracledb langsung jika:
  ✓ Aplikasi enterprise yang banyak menggunakan stored procedure PL/SQL
  ✓ Butuh fitur Oracle spesifik: REF CURSOR, RETURNING INTO, executeMany
  ✓ Performa kritis dengan query yang sudah dioptimasi DBA
  ✓ Tim sudah familiar dengan PL/SQL dan Oracle
  ✓ Menggunakan fitur khusus Oracle: Partitioning, Advanced Queuing, Flashback

Pertimbangkan ORM / Query Builder jika:
  ✗ Banyak model dengan relasi — TypeORM mendukung Oracle via oracledb driver
  ✗ Tim kurang familiar PL/SQL — ORM membantu tapi bisa menyembunyikan performa
  ✗ Butuh migrasi otomatis — TypeORM migrations untuk Oracle
  ✗ Multi-database — TypeORM atau Knex.js (Knex support Oracle terbatas)
  ✗ Rapid prototyping — TypeORM lebih cepat untuk development awal
AspekoracledbTypeORM + OracleKnex.js + Oracle
Kontrol SQLPenuhSebagianBaik
PL/SQL / Stored Proc✓ NativeTerbatasTerbatas
REF CURSORTidakTidak
RETURNING INTOTidak langsungTidak
Migrasi otomatisManualVia Knex
Learning curveSedangSedangRendah

Ringkasan #

  • connection.close() mengembalikan ke pool, tidak menutup permanen — selalu panggil di blok finally; lupa memanggil close() menghabiskan slot pool secara permanen.
  • oracledb.outFormat = OUT_FORMAT_OBJECT harus di-set global di awal aplikasi — tanpa ini hasil query dikembalikan sebagai array posisional yang sulit dipakai dengan tipe TypeScript.
  • Nama kolom Oracle selalu kapital secara default (ID, NAMA, bukan id, nama) — gunakan alias kolom dengan double-quote dalam SQL atau buat fungsi mapper dari UserRow ke User.
  • Bind parameter menggunakan :namaParam — berbeda dari MySQL (?) dan MSSQL (@nama); bind by name lebih aman dan mudah dibaca daripada bind by position (:1, :2).
  • Tidak ada auto-increment bawaan sebelum Oracle 12c — gunakan SEQUENCE dengan seq.NEXTVAL di INSERT, atau GENERATED ALWAYS AS IDENTITY untuk Oracle 12c ke atas.
  • RETURNING INTO untuk mendapat nilai setelah INSERT/UPDATE — deklarasikan bind output dengan { dir: oracledb.BIND_OUT, type: oracledb.NUMBER }.
  • executeMany untuk bulk insert — jauh lebih efisien dari loop satu per satu; serahkan array bind ke driver dan biarkan Oracle mengoptimalkan pengirimannya.
  • Transaksi dimulai otomatis saat query pertama di Oracle — tidak ada BEGIN TRANSACTION; selalu akhiri dengan commit() atau rollback() secara eksplisit karena autoCommit sebaiknya false.
  • sessionCallback di pool config untuk inisialisasi sesi — tempat yang tepat untuk ALTER SESSION seperti NLS format tanggal dan timezone agar konsisten di seluruh koneksi.
  • Nama bind yang sama butuh alias berbeda — jika nilai yang sama dipakai dua kali dalam satu query, buat dua bind dengan nama berbeda (:delta dan :delta2); Oracle tidak bisa menggunakan satu bind dua kali dalam konteks berbeda.

← Sebelumnya: MSSQL   Berikutnya: PostgreSQL →

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