Query Builder
Helper untuk membangun query SQL secara programatik
Query Builder (db.table()) adalah antarmuka query database di RESTForge yang memungkinkan developer mengeksekusi operasi database tanpa menulis SQL secara langsung. Query Builder tersedia di dalam Processor melalui services.db dan menghasilkan SQL yang sesuai secara otomatis berdasarkan database yang aktif (PostgreSQL, MySQL, atau Oracle).
Prinsip Dasar (Basic Principles)
- Setiap query diawali dengan
db.table('nama_tabel') - Method-method berikutnya dirangkai secara berantai (method chaining)
- Query tidak dieksekusi sampai terminal method dipanggil:
.get(),.first(),.insert(),.update(),.delete(),.count(), atau.exists() - Semua nilai parameter aman dari SQL injection secara otomatis (parameterized query)
Anatomi Query (Query Anatomy)
db.table('nama_tabel')
.select(...) ← opsional: pilih kolom
.where(...) ← opsional: filter baris
.orderBy(...) ← opsional: urutan hasil
.limit(...) ← opsional: batasi jumlah baris
.offset(...) ← opsional: lewati sejumlah baris
.with(...) ← opsional: relasi has-many
.withOne(...) ← opsional: relasi has-one
.get() ← terminal: eksekusi query
.flatten() ← post-processor (opsional)Pemilihan Tabel dan Kolom (Table and Column Selection)
db.table()
Menentukan tabel target operasi. Mendukung prefix schema dengan notasi titik.
db.table('contact')
db.table('auth.user')
db.table('inventory.stock_item')select()
Menentukan kolom yang dikembalikan. Jika tidak dipanggil, seluruh kolom dikembalikan (*).
db.table('contact')
.select('contact_id', 'contact_name', 'email')
.get()Kondisi Filter (WHERE Conditions)
Semua kondisi filter digabungkan dengan operator AND.
where() — Kesetaraan dan Perbandingan (Equality and Comparison)
Object style untuk kondisi kesetaraan:
db.table('contact')
.where({ city: 'Surabaya', is_active: true })
.get()Operator style untuk perbandingan (=, !=, >, >=, <, <=):
db.table('contact')
.where('created_at', '>=', '2026-01-01')
.get()Beberapa kondisi dapat dirangkai:
db.table('contact')
.where({ is_active: true })
.where('created_at', '>=', '2026-01-01')
.where('created_at', '<=', '2026-12-31')
.get()whereIn() dan whereNotIn()
db.table('contact')
.whereIn('city', ['Surabaya', 'Jakarta', 'Bandung'])
.get()
db.table('contact')
.whereNotIn('contact_id', ['id-1', 'id-2'])
.get()whereNull() dan whereNotNull()
db.table('contact')
.whereNull('email')
.get()
db.table('contact')
.whereNotNull('email')
.get()whereLike() — Pencarian Teks (Text Search)
Pencarian bersifat case-insensitive di semua database. Karakter wildcard: % (nol atau lebih karakter), _ (tepat satu karakter).
db.table('contact')
.whereLike('contact_name', '%budi%')
.get()| Database | Implementasi Internal |
|---|---|
| PostgreSQL | ILIKE |
| MySQL | LIKE (case-insensitive via collation) |
| Oracle | UPPER(kolom) LIKE UPPER(pola) |
whereBetween()
db.table('orders')
.whereBetween('order_date', '2026-01-01', '2026-12-31')
.get()Pengurutan dan Pembatasan (Ordering and Limiting)
orderBy()
// Ascending (default)
db.table('contact')
.orderBy('contact_name')
.get()
// Descending
db.table('orders')
.orderBy('order_date', 'desc')
.get()
// Multi-kolom
db.table('contact')
.orderBy('city', 'asc')
.orderBy('contact_name', 'asc')
.get()limit() dan offset()
// 10 baris pertama
db.table('contact')
.orderBy('contact_name')
.limit(10)
.get()
// Halaman 2 (baris 11-20)
db.table('contact')
.orderBy('contact_name')
.limit(10)
.offset(10)
.get()Rumus pagination: offset = (nomor_halaman - 1) x ukuran_halaman. Pada Oracle, LIMIT n otomatis dikonversi menjadi FETCH FIRST n ROWS ONLY.
Terminal Method: Pengambilan Data (Data Retrieval)
get() — Ambil Semua Baris (Fetch All Rows)
Mengembalikan array of objects. Jika tidak ada data, mengembalikan [].
const contacts = await db.table('contact')
.where({ is_active: true })
.orderBy('contact_name')
.get()first() — Ambil Satu Baris (Fetch Single Row)
Mengembalikan satu object, atau null jika tidak ada data. Secara internal menambahkan LIMIT 1.
const contact = await db.table('contact')
.where({ contact_id: 'abc-123' })
.first()count() — Hitung Jumlah Baris (Count Rows)
Mengembalikan integer.
const total = await db.table('contact')
.where({ is_active: true })
.count()exists() — Cek Keberadaan (Check Existence)
Mengembalikan true atau false. Lebih efisien dari count() > 0 karena berhenti begitu satu baris ditemukan.
const emailTaken = await db.table('contact')
.where({ email: 'budi@email.com' })
.exists()Terminal Method: Manipulasi Data (Data Manipulation)
insert() — Tambah Baris Baru (Insert New Row)
const newContact = await db.table('contact')
.returning('*')
.insert({
contact_name: 'Budi Santoso',
email: 'budi@email.com',
city: 'Surabaya'
}).returning() wajib dipanggil sebelum .insert(), bukan sesudahnya. Tanpa .returning(), insert mengembalikan null.
| Database | Implementasi returning() |
|---|---|
| PostgreSQL | RETURNING * (native) |
| MySQL | SELECT ulang setelah insert |
| Oracle | RETURNING ... INTO bind variable |
update() — Ubah Data (Update Data)
const updated = await db.table('contact')
.where({ contact_id: 'abc-123' })
.returning('*')
.update({
contact_name: 'Budi Santoso',
email: 'budi.baru@email.com'
})update() wajib didahului minimal satu .where(). Tanpa kondisi WHERE, query menghasilkan QueryBuilderError untuk mencegah pembaruan seluruh tabel secara tidak sengaja.
delete() — Hapus Baris (Delete Row)
Mengembalikan jumlah baris yang dihapus (integer).
const deletedCount = await db.table('contact')
.where({ contact_id: 'abc-123' })
.delete()delete() wajib didahului minimal satu .where(). Tanpa kondisi WHERE, query menghasilkan QueryBuilderError.
Relation Helper
Relation Helper mengambil data dari tabel yang berelasi menggunakan strategi batch loading yaitu selalu dua query ke database, berapapun jumlah baris induk.
with() — Relasi Has-Many
.with(tabel_relasi, foreign_key [, local_key [, alias [, callback]]])Data relasi dipasangkan sebagai array ([] jika tidak ada relasi).
// Satu relasi
const contact = await db.table('contact')
.where({ contact_id: 'abc-123' })
.with('orders', 'contact_id', 'contact_id')
.first()
// Beberapa relasi sekaligus
const contact = await db.table('contact')
.where({ contact_id: 'abc-123' })
.with('orders', 'contact_id', 'contact_id')
.with('contact_addresses', 'contact_id', 'contact_id')
.first()
// Dengan alias
const contact = await db.table('contact')
.where({ contact_id: 'abc-123' })
.with('orders', 'contact_id', 'contact_id', 'riwayat_order')
.first()
// Dengan filter callback
const contact = await db.table('contact')
.where({ contact_id: 'abc-123' })
.with(
'orders', 'contact_id', 'contact_id', null,
(q) => q.where({ status: 'confirmed' }).orderBy('order_date', 'desc').limit(5)
)
.first()withOne() — Relasi Has-One
Data relasi dipasangkan sebagai object tunggal (null jika tidak ada relasi).
const contact = await db.table('contact')
.where({ contact_id: 'abc-123' })
.withOne('contact_profile', 'contact_id', 'contact_id', 'profile')
.first()Nested Relation — Relasi Bertingkat
Relasi bertingkat didukung maksimal 2 level menggunakan callback. Untuk relasi lebih dari 2 level, gunakan db.executeQuery() dengan JOIN.
const contact = await db.table('contact')
.where({ contact_id: 'abc-123' })
.with(
'orders', 'contact_id', 'contact_id', null,
(q) => q
.where({ status: 'confirmed' })
.orderBy('order_date', 'desc')
.with('order_items', 'order_id', 'order_id')
)
.first()Post-Processor: flatten()
flatten() mengubah output nested yang dihasilkan with() atau withOne() menjadi struktur flat, identik dengan hasil SQL JOIN. Method ini dipanggil setelah terminal method .get() atau .first().
// Output nested (tanpa flatten)
const nested = await db.table('contact')
.where({ contact_id: 'abc-123' })
.with('orders', 'contact_id', 'contact_id')
.first()
// { contact_id: 'abc-123', contact_name: 'Budi',
// orders: [{ order_id: '1', ... }, { order_id: '2', ... }] }
// Output flat (dengan flatten)
const flat = await db.table('contact')
.where({ contact_id: 'abc-123' })
.with('orders', 'contact_id', 'contact_id')
.first()
.flatten()
// [{ contact_id: 'abc-123', contact_name: 'Budi', order_id: '1', ... },
// { contact_id: 'abc-123', contact_name: 'Budi', order_id: '2', ... }]Perilaku flatten() (Flatten Behavior)
| Kondisi | Perilaku |
|---|---|
| Induk punya relasi (has-many) | Satu baris per kombinasi induk + baris relasi |
| Induk tidak punya relasi | Satu baris induk, kolom relasi bernilai null (LEFT JOIN behavior) |
| Relasi has-one | Satu baris per induk, kolom relasi di-merge langsung |
| Konflik nama kolom | Kolom induk dipertahankan, kolom relasi mendapat prefix {nama_tabel}.{kolom} |
Beberapa with() sekaligus | Cartesian product dari semua relasi |
Tanpa with() sebelumnya | QueryBuilderError |
Untuk kasus multi-relasi yang menghasilkan cartesian product tidak diinginkan, gunakan db.executeQuery() dengan JOIN eksplisit.
Contoh Implementasi di Processor (Processor Examples)
Filter Dinamis dengan Pagination
const processor = {
async process(input, services, req) {
const { db, createResponse, createError } = services;
try {
let query = db.table('contact')
.select('contact_id', 'contact_name', 'email', 'city')
.where({ is_active: true });
if (input.keyword) query = query.whereLike('contact_name', `%${input.keyword}%`);
if (input.city) query = query.where({ city: input.city });
const page = parseInt(input.page) || 1;
const pageSize = parseInt(input.limit) || 10;
const total = await query.count();
const rows = await query
.orderBy('contact_name', 'asc')
.limit(pageSize)
.offset((page - 1) * pageSize)
.get();
return createResponse(200, 'Data contact ditemukan.', {
rows, total, page, page_size: pageSize,
total_pages: Math.ceil(total / pageSize)
});
} catch (error) {
return createError(500, `Gagal mengambil data: ${error.message}`);
}
}
};
module.exports = processor;Data Relasi untuk Detail Page
const processor = {
async process(input, services, req) {
const { db, createResponse, createError } = services;
try {
const contact = await db.table('contact')
.where({ contact_id: input.contact_id, is_active: true })
.withOne('contact_profile', 'contact_id', 'contact_id', 'profile')
.with('orders', 'contact_id', 'contact_id', null,
(q) => q.where({ status: 'confirmed' }).orderBy('order_date', 'desc').limit(10)
.with('order_items', 'order_id', 'order_id', 'items')
)
.first();
if (!contact) return createError(404, 'Contact tidak ditemukan.');
return createResponse(200, 'Data contact ditemukan.', contact);
} catch (error) {
return createError(500, `Gagal mengambil data: ${error.message}`);
}
}
};
module.exports = processor;Data Flat untuk Tabel (DataTables / TanStack Table)
const processor = {
async process(input, services, req) {
const { db, createResponse, createError } = services;
try {
const rows = await db.table('contact')
.select('contact_id', 'contact_name', 'city')
.where({ is_active: true })
.with('orders', 'contact_id', 'contact_id', null,
(q) => q.where({ status: 'confirmed' })
)
.get()
.flatten();
return createResponse(200, 'Data siap untuk tabel.', rows);
} catch (error) {
return createError(500, `Gagal mengambil data: ${error.message}`);
}
}
};
module.exports = processor;Kapan Menggunakan db.executeQuery() (When to Use Raw SQL)
| Kebutuhan | Gunakan |
|---|---|
| Query satu tabel dengan filter | db.table() |
| Data relasi nested (detail page) | db.table().with().first() |
| Data relasi flat (tabel UI) | db.table().with().get().flatten() |
| JOIN + GROUP BY + agregasi | db.executeQuery() |
| Relasi lebih dari 2 level | db.executeQuery() |
| Subquery, CTE, atau Window Function | db.executeQuery() |
Ringkasan Method (Method Summary)
| Method | Kategori | Keterangan |
|---|---|---|
db.table(tabel) | Entry point | Mulai query pada tabel tertentu |
.select(...kolom) | Kolom | Pilih kolom yang dikembalikan |
.where(kondisi) | Filter | Kondisi kesetaraan atau perbandingan |
.whereIn(kolom, array) | Filter | Nilai ada dalam daftar |
.whereNotIn(kolom, array) | Filter | Nilai tidak ada dalam daftar |
.whereNull(kolom) | Filter | Nilai adalah NULL |
.whereNotNull(kolom) | Filter | Nilai bukan NULL |
.whereLike(kolom, pola) | Filter | Pencarian teks case-insensitive |
.whereBetween(kolom, min, max) | Filter | Nilai dalam rentang |
.orderBy(kolom, arah) | Urutan | Tentukan urutan hasil |
.limit(n) | Pembatasan | Batasi jumlah baris |
.offset(n) | Pembatasan | Lewati sejumlah baris |
.with(...) | Relasi | Relasi has-many (array) |
.withOne(...) | Relasi | Relasi has-one (object) |
.returning(...) | Modifier | Kembalikan data setelah insert/update |
.get() | Terminal | Ambil semua baris → array |
.first() | Terminal | Ambil satu baris → object atau null |
.insert(data) | Terminal | Tambah baris baru |
.update(data) | Terminal | Ubah baris (wajib .where()) |
.delete() | Terminal | Hapus baris (wajib .where()) |
.count(kolom?) | Terminal | Hitung jumlah baris → integer |
.exists() | Terminal | Cek keberadaan → true/false |
.flatten() | Post-processor | Ubah output nested ke flat array |
Langkah Selanjutnya (Next Steps)
- Query Deklaratif untuk konfigurasi query berbasis payload JSON
- Sort Columns untuk pengurutan multi-kolom
- Processor untuk panduan lengkap penulisan business logic