MSSQL #

Microsoft SQL Server adalah database enterprise yang sangat umum di lingkungan korporat dan aplikasi berbasis stack Microsoft. Mengintegrasikannya dengan TypeScript menggunakan library mssql memberi kamu akses ke fitur-fitur SQL Server seperti stored procedure, table-valued parameters, dan output parameters — semuanya dengan type safety yang solid. Ada perbedaan penting dalam cara mssql bekerja dibanding mysql2: parameter tidak menggunakan ? melainkan named parameter dengan prefix @, dan tipe data kolom harus dideklarasikan secara eksplisit saat menggunakan Request. Memahami perbedaan ini sejak awal akan menghindarkan kamu dari banyak kebingungan saat pertama kali bermigrasi dari MySQL atau PostgreSQL ke SQL Server.

Instalasi dan Setup #

Library utama yang digunakan adalah mssql beserta type definitions-nya yang sudah disertakan dalam paket yang sama.

# Instalasi paket
npm install mssql

# Type definitions sudah termasuk dalam mssql
# Tidak perlu @types/mssql terpisah
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "strict": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

Struktur project yang direkomendasikan:

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

Konfigurasi Koneksi dan Connection Pool #

mssql mengelola koneksi melalui ConnectionPool. Berbeda dengan mysql2, pool di mssql perlu di-connect secara eksplisit sebelum bisa digunakan.

import sql, { config as SqlConfig, ConnectionPool } from 'mssql';

// Konfigurasi koneksi
const dbConfig: SqlConfig = {
  server: process.env.DB_HOST ?? 'localhost',
  port: Number(process.env.DB_PORT ?? 1433),
  database: process.env.DB_NAME ?? 'toko_online',
  user: process.env.DB_USER ?? 'sa',
  password: process.env.DB_PASSWORD ?? '',
  options: {
    encrypt: true,              // wajib untuk Azure SQL
    trustServerCertificate: true, // untuk development lokal
    enableArithAbort: true,     // direkomendasikan SQL Server 2017+
  },
  pool: {
    max: 10,                    // maksimum koneksi bersamaan
    min: 0,                     // minimum koneksi idle
    idleTimeoutMillis: 30000,   // tutup koneksi idle setelah 30 detik
  },
  requestTimeout: 30000,        // timeout per query dalam ms
  connectionTimeout: 15000,     // timeout saat membuka koneksi
};

// Singleton pool
let poolInstance: ConnectionPool | null = null;

export async function getPool(): Promise<ConnectionPool> {
  if (!poolInstance) {
    poolInstance = new sql.ConnectionPool(dbConfig);
    await poolInstance.connect();

    // Handle error pada pool
    poolInstance.on('error', (err) => {
      console.error('SQL Pool error:', err);
      poolInstance = null;  // reset agar koneksi bisa dibuat ulang
    });
  }
  return poolInstance;
}

export async function closePool(): Promise<void> {
  if (poolInstance) {
    await poolInstance.close();
    poolInstance = null;
  }
}

// Graceful shutdown
process.on('SIGTERM', closePool);
process.on('SIGINT', closePool);
flowchart TD
    A[Aplikasi start] --> B[new ConnectionPool\ncreate config]
    B --> C[pool.connect]
    C --> D{Koneksi\nberhasil?}
    D -- Ya --> E[Pool siap\nterima request]
    D -- Tidak --> F[Throw error\nkoneksi gagal]
    E --> G[Request masuk]
    G --> H[pool.request]
    H --> I[Eksekusi query]
    I --> J[Koneksi kembali\nke pool]
    J --> E
mssql memerlukan pemanggilan pool.connect() secara eksplisit sebelum pool bisa digunakan. Jika kamu langsung memanggil pool.request() tanpa connect(), kamu akan mendapat error "Connection not yet open". Pola singleton yang memanggil connect() saat inisialisasi pertama kali adalah cara paling aman untuk menghindari masalah ini.

Mendefinisikan Tipe untuk Row Database #

Berbeda dari mysql2 yang menggunakan RowDataPacket, tipe result di mssql lebih sederhana — kamu cukup mendefinisikan interface biasa dan menggunakannya sebagai type parameter.

import sql from 'mssql';

// Tipe untuk tabel users
interface User {
  id: number;
  nama: string;
  email: string;
  passwordHash: string;
  role: 'admin' | 'user' | 'moderator';
  aktif: boolean;
  dibuatPada: Date;
  diperbarui_pada: Date | null;
}

interface Produk {
  id: number;
  nama: string;
  deskripsi: string | null;
  harga: number;         // DECIMAL di SQL Server — hati-hati presisi
  stok: number;
  kategoriId: number;
  gambarUrl: string | null;
  dibuatPada: Date;
}

// Pemetaan tipe TypeScript ke tipe SQL Server
// Penting untuk parameter input
const SQL_TYPES = {
  INT: sql.Int,
  BIGINT: sql.BigInt,
  VARCHAR: (n: number) => sql.VarChar(n),
  NVARCHAR: (n: number) => sql.NVarChar(n),
  BIT: sql.Bit,                  // boolean
  DECIMAL: (p: number, s: number) => sql.Decimal(p, s),
  FLOAT: sql.Float,
  DATETIME2: sql.DateTime2,
  UNIQUEIDENTIFIER: sql.UniqueIdentifier,
} as const;

Tipe data SQL Server memiliki padanan TypeScript yang perlu kamu perhatikan:

SQL Server TypeTypeScript TypeCatatan
INT, SMALLINTnumberInteger 32-bit
BIGINTstring | numberBisa overflow JS number
VARCHAR(n), NVARCHAR(n)stringN prefix = Unicode
BITboolean0/1 otomatis dikonversi
DECIMAL(p,s), NUMERICnumberHati-hati presisi floating point
DATETIME2, DATETIMEDateKonversi otomatis oleh driver
UNIQUEIDENTIFIERstringGUID dalam format string
VARBINARYBufferData biner

Query Dasar — SELECT #

Cara kerja parameterized query di mssql berbeda signifikan dari MySQL. Parameter menggunakan named syntax @namaParam dan harus dideklarasikan dengan tipe data SQL yang eksplisit.

import { getPool } from './db/connection';
import sql from 'mssql';

// ── SELECT sederhana tanpa parameter
async function semuaUser(): Promise<User[]> {
  const pool = await getPool();
  const result = await pool.request()
    .query<User>('SELECT id, nama, email, role FROM users WHERE aktif = 1');
  return result.recordset;  // array of rows
}

// ── SELECT dengan parameter — WAJIB deklarasikan tipe!
async function cariUserById(id: number): Promise<User | null> {
  const pool = await getPool();
  const result = await pool.request()
    .input('id', sql.Int, id)          // nama, tipe SQL, nilai
    .query<User>('SELECT * FROM users WHERE id = @id AND aktif = 1');
  return result.recordset[0] ?? null;
}

// ── SELECT dengan multiple parameter
async function produkByKategoriDanHarga(
  kategoriId: number,
  hargaMin: number,
  hargaMax: number
): Promise<Produk[]> {
  const pool = await getPool();
  const result = await pool.request()
    .input('kategoriId', sql.Int, kategoriId)
    .input('hargaMin', sql.Decimal(18, 2), hargaMin)
    .input('hargaMax', sql.Decimal(18, 2), hargaMax)
    .query<Produk>(`
      SELECT *
      FROM produk
      WHERE kategori_id = @kategoriId
        AND harga BETWEEN @hargaMin AND @hargaMax
        AND stok > 0
      ORDER BY harga ASC
    `);
  return result.recordset;
}

// ── SELECT dengan LIKE
async function cariProdukByNama(keyword: string): Promise<Produk[]> {
  const pool = await getPool();
  // Escape karakter khusus LIKE di SQL Server: %, _, [, ^
  const safeKeyword = keyword.replace(/[%_\[\^]/g, '[$&]');
  const result = await pool.request()
    .input('keyword', sql.NVarChar(200), `%${safeKeyword}%`)
    .query<Produk>(`
      SELECT TOP 20 * FROM produk
      WHERE nama LIKE @keyword
      ORDER BY nama
    `);
  return result.recordset;
}
// ANTI-PATTERN: string concatenation langsung
async function cariUserTidakAman(email: string): Promise<User[]> {
  const pool = await getPool();
  // Rentan SQL injection!
  const result = await pool.request()
    .query<User>(`SELECT * FROM users WHERE email = '${email}'`);
  return result.recordset;
}

// BENAR: selalu deklarasikan parameter dengan .input()
async function cariUserAman(email: string): Promise<User | null> {
  const pool = await getPool();
  const result = await pool.request()
    .input('email', sql.NVarChar(255), email)
    .query<User>('SELECT * FROM users WHERE email = @email');
  return result.recordset[0] ?? null;
}

Operasi INSERT #

SQL Server menyediakan klausa OUTPUT yang sangat berguna untuk mendapatkan kembali nilai kolom dari baris yang baru disisipkan — termasuk ID auto-increment dan kolom dengan default value.

import sql from 'mssql';
import { getPool } from './db/connection';

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

// ── INSERT dengan OUTPUT untuk mendapat ID
async function buatUser(input: InputUser): Promise<number> {
  const pool = await getPool();
  const result = await pool.request()
    .input('nama', sql.NVarChar(100), input.nama)
    .input('email', sql.NVarChar(255), input.email)
    .input('passwordHash', sql.VarChar(255), input.passwordHash)
    .input('role', sql.VarChar(20), input.role ?? 'user')
    .query<{ id: number }>(`
      INSERT INTO users (nama, email, password_hash, role, aktif, dibuat_pada)
      OUTPUT INSERTED.id
      VALUES (@nama, @email, @passwordHash, @role, 1, GETDATE())
    `);
  return result.recordset[0].id;
}

// ── INSERT dan kembalikan seluruh baris yang baru dibuat
async function buatProduk(
  input: Omit<Produk, 'id' | 'dibuatPada'>
): Promise<Produk> {
  const pool = await getPool();
  const result = await pool.request()
    .input('nama', sql.NVarChar(200), input.nama)
    .input('deskripsi', sql.NVarChar(sql.MAX), input.deskripsi)
    .input('harga', sql.Decimal(18, 2), input.harga)
    .input('stok', sql.Int, input.stok)
    .input('kategoriId', sql.Int, input.kategoriId)
    .query<Produk>(`
      INSERT INTO produk (nama, deskripsi, harga, stok, kategori_id, dibuat_pada)
      OUTPUT INSERTED.*
      VALUES (@nama, @deskripsi, @harga, @stok, @kategoriId, GETDATE())
    `);
  return result.recordset[0];
}

// ── INSERT batch dengan Table-Valued Parameter (TVP)
// TVP adalah fitur SQL Server untuk memasukkan banyak baris sekaligus secara efisien
async function buatBanyakProduk(
  produkList: Array<{ nama: string; harga: number; stok: number; kategoriId: number }>
): Promise<number> {
  const pool = await getPool();

  // Buat TVP table
  const tvp = new sql.Table();
  tvp.columns.add('nama', sql.NVarChar(200), { nullable: false });
  tvp.columns.add('harga', sql.Decimal(18, 2), { nullable: false });
  tvp.columns.add('stok', sql.Int, { nullable: false });
  tvp.columns.add('kategori_id', sql.Int, { nullable: false });

  for (const p of produkList) {
    tvp.rows.add(p.nama, p.harga, p.stok, p.kategoriId);
  }

  const result = await pool.request()
    .input('produkList', tvp)
    .execute('sp_InsertBanyakProduk');  // stored procedure yang menerima TVP

  return result.rowsAffected[0];
}
sequenceDiagram
    participant App
    participant Pool
    participant SqlServer

    App->>Pool: request().input('nama', type, val)
    Note over Pool: Parameter ditambahkan ke request
    App->>Pool: .query(INSERT...OUTPUT INSERTED.*)
    Pool->>SqlServer: Kirim query dengan parameter
    SqlServer-->>Pool: recordset dengan baris yang diinsert
    Pool-->>App: result.recordset[0]
    Note over App: Dapat seluruh data baris baru

Operasi UPDATE dan DELETE #

UPDATE di SQL Server juga bisa memanfaatkan OUTPUT untuk mendapatkan nilai sebelum dan sesudah perubahan — berguna untuk audit log.

import sql from 'mssql';
import { getPool } from './db/connection';

// ── UPDATE dengan OUTPUT — tahu nilai sebelum dan sesudah
async function updateStokProduk(
  id: number,
  deltaStok: number
): Promise<{ stokLama: number; stokBaru: number } | null> {
  const pool = await getPool();
  const result = await pool.request()
    .input('id', sql.Int, id)
    .input('delta', sql.Int, deltaStok)
    .query<{ stokLama: number; stokBaru: number }>(`
      UPDATE produk
      SET stok = stok + @delta
      OUTPUT DELETED.stok AS stokLama,
             INSERTED.stok AS stokBaru
      WHERE id = @id AND (stok + @delta) >= 0
    `);

  return result.recordset[0] ?? null;  // null jika stok tidak cukup
}

// ── 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 field yang boleh diupdate
  const allowedFields: Record<string, [string, unknown]> = {
    nama: ['nama', sql.NVarChar(100)],
    email: ['email', sql.NVarChar(255)],
  };

  const pool = await getPool();
  const request = pool.request().input('id', sql.Int, id);
  const setClauses: string[] = [];

  for (const [key, value] of entries) {
    if (!allowedFields[key]) continue;
    const [sqlType] = allowedFields[key];
    request.input(`p_${key}`, sqlType as sql.ISqlType, value);
    setClauses.push(`${key} = @p_${key}`);
  }

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

  const result = await request.query(`
    UPDATE users
    SET ${setClauses.join(', ')}, diperbarui_pada = GETDATE()
    WHERE id = @id
  `);

  return result.rowsAffected[0] > 0;
}

// ── DELETE — hard delete
async function hapusProduk(id: number): Promise<boolean> {
  const pool = await getPool();
  const result = await pool.request()
    .input('id', sql.Int, id)
    .query('DELETE FROM produk WHERE id = @id');
  return result.rowsAffected[0] > 0;
}

// ── Soft delete
async function softDeleteUser(id: number): Promise<boolean> {
  const pool = await getPool();
  const result = await pool.request()
    .input('id', sql.Int, id)
    .query(`
      UPDATE users
      SET aktif = 0, dihapus_pada = GETDATE()
      WHERE id = @id AND aktif = 1
    `);
  return result.rowsAffected[0] > 0;
}

Transaction #

Transaction di mssql menggunakan objek Transaction yang harus di-commit atau di-rollback secara eksplisit. Setiap Request dalam transaksi harus dibuat dari objek Transaction, bukan langsung dari pool.

import sql, { Transaction, Request } from 'mssql';
import { getPool } from './db/connection';

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

async function buatPesanan(
  userId: number,
  items: ItemPesanan[]
): Promise<number> {
  const pool = await getPool();
  const transaction = new sql.Transaction(pool);

  try {
    await transaction.begin();

    const totalHarga = items.reduce(
      (sum, item) => sum + item.hargaSatuan * item.jumlah, 0
    );

    // 1. Buat record pesanan
    const r1 = await new sql.Request(transaction)
      .input('userId', sql.Int, userId)
      .input('total', sql.Decimal(18, 2), totalHarga)
      .query<{ id: number }>(`
        INSERT INTO pesanan (user_id, total_harga, status, dibuat_pada)
        OUTPUT INSERTED.id
        VALUES (@userId, @total, 'pending', GETDATE())
      `);

    const pesananId = r1.recordset[0].id;

    // 2. Proses setiap item
    for (const item of items) {
      // Sisipkan item pesanan
      await new sql.Request(transaction)
        .input('pesananId', sql.Int, pesananId)
        .input('produkId', sql.Int, item.produkId)
        .input('jumlah', sql.Int, item.jumlah)
        .input('harga', sql.Decimal(18, 2), item.hargaSatuan)
        .query(`
          INSERT INTO pesanan_item
            (pesanan_id, produk_id, jumlah, harga_satuan)
          VALUES (@pesananId, @produkId, @jumlah, @harga)
        `);

      // Kurangi stok — UPDATE gagal jika stok tidak cukup
      const rStok = await new sql.Request(transaction)
        .input('jumlah', sql.Int, item.jumlah)
        .input('produkId', sql.Int, item.produkId)
        .query(`
          UPDATE produk
          SET stok = stok - @jumlah
          WHERE id = @produkId AND stok >= @jumlah
        `);

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

    await transaction.commit();
    return pesananId;

  } catch (error) {
    // Rollback jika belum di-rollback (misalnya error tak terduga)
    try {
      await transaction.rollback();
    } catch {
      // Abaikan error rollback jika transaksi sudah di-rollback
    }
    throw error;
  }
}

Helper untuk menyederhanakan pola transaction:

async function withTransaction<T>(
  callback: (transaction: Transaction) => Promise<T>
): Promise<T> {
  const pool = await getPool();
  const transaction = new sql.Transaction(pool);
  await transaction.begin();
  try {
    const result = await callback(transaction);
    await transaction.commit();
    return result;
  } catch (error) {
    try { await transaction.rollback(); } catch { /* sudah rollback */ }
    throw error;
  }
}

// Penggunaan
const pesananId = await withTransaction(async (trx) => {
  const r1 = await new sql.Request(trx)
    .input('userId', sql.Int, userId)
    .query<{ id: number }>(`
      INSERT INTO pesanan (user_id) OUTPUT INSERTED.id VALUES (@userId)
    `);
  return r1.recordset[0].id;
});
flowchart TD
    A[new sql.Transaction pool] --> B[transaction.begin]
    B --> C[new sql.Request transaction]
    C --> D[Eksekusi query]
    D --> E{Error?}
    E -- Tidak --> F{Masih ada\noperasi?}
    F -- Ya --> C
    F -- Tidak --> G[transaction.commit]
    E -- Ya --> H[transaction.rollback]
    G --> I[Selesai sukses]
    H --> J[Re-throw error]

Stored Procedure #

SQL Server banyak menggunakan stored procedure. mssql mendukung pemanggilan SP dengan parameter input, output, dan return value.

import sql from 'mssql';
import { getPool } from './db/connection';

// ── Memanggil stored procedure sederhana
async function spCariUser(email: string): Promise<User | null> {
  const pool = await getPool();
  const result = await pool.request()
    .input('email', sql.NVarChar(255), email)
    .execute<User>('sp_CariUserByEmail');
  return result.recordset[0] ?? null;
}

// ── SP dengan OUTPUT parameter
async function spDaftarUser(
  input: InputUser
): Promise<{ userId: number; pesan: string }> {
  const pool = await getPool();
  const result = await pool.request()
    .input('nama', sql.NVarChar(100), input.nama)
    .input('email', sql.NVarChar(255), input.email)
    .input('passwordHash', sql.VarChar(255), input.passwordHash)
    .output('userId', sql.Int)           // deklarasikan OUTPUT
    .output('pesan', sql.NVarChar(500))
    .execute('sp_DaftarUser');

  return {
    userId: result.output.userId as number,
    pesan: result.output.pesan as string,
  };
}

// ── SP dengan return value (RETURN statement di SP)
async function spCekStok(produkId: number): Promise<number> {
  const pool = await getPool();
  const result = await pool.request()
    .input('produkId', sql.Int, produkId)
    .execute('sp_CekStok');

  // Return value ada di result.returnValue
  return result.returnValue as number;
}

Error Handling #

SQL Server memiliki kode error yang berbeda dari MySQL. Penting untuk memetakan error spesifik ke domain error yang bermakna.

import sql from 'mssql';

// Kode error SQL Server yang sering ditemui
const MSSQL_ERRORS = {
  UNIQUE_VIOLATION: 2627,        // UNIQUE constraint violation
  PK_VIOLATION: 2627,            // PRIMARY KEY violation (sama dengan unique)
  FK_VIOLATION: 547,             // FOREIGN KEY constraint violation
  NULL_VIOLATION: 515,           // Cannot insert NULL
  VALUE_TOO_LONG: 8152,          // String or binary data would be truncated
  DEADLOCK: 1205,                // Transaction deadlock
  TIMEOUT: -2,                   // Query timeout
  LOGIN_FAILED: 18456,           // Login failed
  OBJECT_NOT_FOUND: 208,         // Invalid object name (tabel tidak ada)
} as const;

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

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

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 tanganiMssqlError(error: unknown): never {
  if (error instanceof sql.RequestError) {
    switch (error.number) {
      case MSSQL_ERRORS.UNIQUE_VIOLATION:
      case MSSQL_ERRORS.PK_VIOLATION:
        throw new DuplicateEntryError(error.message);
      case MSSQL_ERRORS.FK_VIOLATION:
        throw new ForeignKeyError();
      case MSSQL_ERRORS.NULL_VIOLATION:
        throw new DatabaseError('Field wajib tidak boleh kosong', error.number);
      case MSSQL_ERRORS.VALUE_TOO_LONG:
        throw new DatabaseError('Data melebihi panjang maksimum kolom', error.number);
      case MSSQL_ERRORS.DEADLOCK:
        throw new DeadlockError();
    }
  }
  throw new DatabaseError(`Error database: ${String(error)}`);
}

// Decorator retry untuk deadlock — deadlock bisa coba ulang
async function withDeadlockRetry<T>(
  fn: () => Promise<T>,
  maxRetry = 3,
  delayMs = 100
): 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, delayMs * attempt));
        continue;
      }
      throw error;
    }
  }
  throw new Error('Unreachable');
}

// Penggunaan dalam repository
async function buatUserAman(input: InputUser): Promise<number> {
  try {
    return await buatUser(input);
  } catch (error) {
    tanganiMssqlError(error);
  }
}

Paginasi dengan SQL Server #

SQL Server menggunakan sintaks OFFSET ... FETCH NEXT yang tersedia sejak SQL Server 2012. Untuk versi lama, gunakan ROW_NUMBER().

import sql from 'mssql';
import { getPool } from './db/connection';

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

const ALLOWED_SORT: Set<string> = new Set<UserSortField>([
  'nama', 'email', 'dibuat_pada'
]);

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

async function getUserPaginasi(
  halaman: number,
  perHalaman: number,
  sortBy: UserSortField = 'dibuat_pada',
  sortOrder: SortOrder = 'DESC'
): Promise<PaginasiResult<User>> {
  // Validasi whitelist — WAJIB untuk kolom yang diinterpolasi
  const safeSort = ALLOWED_SORT.has(sortBy) ? sortBy : 'dibuat_pada';
  const safeOrder: SortOrder = sortOrder === 'ASC' ? 'ASC' : 'DESC';
  const offset = (halaman - 1) * perHalaman;

  const pool = await getPool();
  const result = await pool.request()
    .input('offset', sql.Int, offset)
    .input('fetch', sql.Int, perHalaman)
    .query<User & { totalRows: number }>(`
      SELECT
        id, nama, email, role, aktif, dibuat_pada,
        COUNT(*) OVER() AS totalRows
      FROM users
      WHERE aktif = 1
      ORDER BY ${safeSort} ${safeOrder}
      OFFSET @offset ROWS
      FETCH NEXT @fetch ROWS ONLY
    `);

  const total = result.recordset[0]?.totalRows ?? 0;

  return {
    data: result.recordset.map(({ totalRows: _, ...user }) => user as User),
    total,
    halaman,
    perHalaman,
    totalHalaman: Math.ceil(total / perHalaman),
  };
}
ORDER BY wajib ada saat menggunakan OFFSET ... FETCH NEXT di SQL Server. Query tanpa ORDER BY akan menghasilkan error "ORDER BY clause is required". Ini berbeda dari MySQL yang tidak memerlukan ORDER BY untuk LIMIT/OFFSET.

Kapan Beralih ke Pendekatan Lain #

Tetap gunakan mssql langsung jika:
  ✓ Aplikasi enterprise yang banyak menggunakan stored procedure
  ✓ Query kompleks yang butuh kontrol penuh atas T-SQL
  ✓ Perlu fitur SQL Server spesifik: TVP, OUTPUT, MERGE
  ✓ Tim sudah familiar dengan T-SQL dan tidak butuh abstraksi ORM
  ✓ Performa kritis dengan query yang sudah dioptimasi DBA

Pertimbangkan ORM / Query Builder jika:
  ✗ Banyak model dengan relasi kompleks — TypeORM dengan SQL Server driver
  ✗ Multi-database (MSSQL + PostgreSQL) — TypeORM atau Knex.js
  ✗ Rapid prototyping — Prisma (mendukung SQL Server sejak versi 2.10)
  ✗ Tim kurang familiar T-SQL — ORM membantu tapi bisa menyembunyikan masalah
  ✗ Butuh migrasi otomatis — TypeORM migrations atau Prisma migrate
AspekmssqlTypeORMPrisma
Kontrol SQLPenuhSebagianTerbatas
Type SafetyManualDekoratorOtomatis dari schema
Stored Procedure✓ NativeTerbatas
OUTPUT clauseTidak langsungTidak
MigrasiManualOtomatisOtomatis
Learning curveRendahSedangRendah

Ringkasan #

  • pool.connect() wajib dipanggil sebelum request — berbeda dari mysql2, pool mssql perlu dikoneksikan secara eksplisit; gunakan pola singleton dengan connect() di inisialisasi pertama.
  • Parameter menggunakan @namaParam, bukan ? — deklarasikan setiap parameter dengan .input('nama', sql.TipeSQL, nilai) sebelum memanggil .query() atau .execute().
  • Tipe SQL harus dideklarasikan eksplisitsql.Int, sql.NVarChar(255), sql.Decimal(18,2); tanpa ini driver bisa salah memetakan tipe dan menyebabkan truncation atau error tipe data.
  • Gunakan OUTPUT INSERTED.* untuk mendapatkan data baris yang baru diinsert — lebih idiomatis di SQL Server dibanding SELECT SCOPE_IDENTITY() terpisah.
  • OUTPUT DELETED.* dan INSERTED.* tersedia di UPDATE untuk audit log — fitur unik SQL Server yang tidak ada di MySQL.
  • new sql.Request(transaction) untuk query dalam transaksi — request tidak dibuat dari pool langsung; selalu buat dari objek Transaction.
  • ORDER BY wajib untuk OFFSET...FETCH NEXT — SQL Server error jika tidak ada ORDER BY, berbeda dari MySQL yang toleran.
  • Validasi whitelist untuk nama kolom dinamis — nama kolom tidak bisa diparameterkan, wajib divalidasi dari Set sebelum diinterpolasi ke query.
  • Retry untuk deadlock — deadlock (error.number === 1205) adalah kondisi yang bisa di-recover; implementasikan logika retry dengan backoff untuk meningkatkan ketahanan aplikasi.
  • sql.NVarChar untuk teks Unicode — gunakan NVarChar (bukan VarChar) untuk string yang mungkin mengandung karakter non-ASCII seperti nama dalam bahasa Arab atau aksara lain.

← Sebelumnya: MySQL   Berikutnya: Oracle →

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