Memperdalam Query Builder: join, subquery, aggregate, dan union, lalu mengelola data konsisten dengan transaksi database serta memanfaatkan model callbacks untuk logika otomatis pada setiap operasi.

Di episode 6-7 kita memakai Query Builder untuk operasi dasar dan model untuk CRUD. Aplikasi nyata tidak berhenti di situ: laporan butuh aggregate, daftar butuh join antar tabel, dan transaksi keuangan menuntut operasi yang tidak boleh setengah jadi. Di episode 21 ini kita memperdalam semua itu.
Mengapa penting? Karena data aplikasi saling berelasi — post punya author, order punya item, kategori punya banyak post. Kemampuan menggabungkan data (join), merangkum (aggregate), dan menjaga konsistensi (transaction) adalah yang membedakan aplikasi "demo" dari aplikasi produksi yang bisa dipercaya.
Query Builder menyediakan method join(). Ambil post beserta nama penulisnya:
$db = \Config\Database::connect();
$posts = $db->table('posts')
->select('posts.id, posts.title, posts.status, users.name AS author')
->join('users', 'users.id = posts.user_id', 'left')
->orderBy('posts.created_at', 'DESC')
->get()
->getResultArray();
// [
// ['id' => 1, 'title' => 'Hello', 'status' => 'published', 'author' => 'Admin'],
// ...
// ]Tipe join: left (semua baris kiri, kanan boleh kosong), inner (hanya yang cocok), right. Aturan praktis: gunakan left saat kolom kiri (misal posts) harus selalu tampil meskipun relasi tidak ada.
Subquery = query di dalam query. Contoh: post yang jumlah komentarnya di atas rata-rata.
$sub = $db->table('comments')
->select('post_id, COUNT(*) AS total')
->groupBy('post_id')
->getCompiledSelect();
$posts = $db->table('posts')
->select('posts.id, posts.title, c.total')
->join("({$sub}) c", 'c.post_id = posts.id', 'left')
->orderBy('c.total', 'DESC')
->get()
->getResultArray();getCompiledSelect() mengambil SQL yang terbentuk tanpa menjalankannya — lalu kita pakai sebagai subquery dalam join.
Tip
Mulailah dari query yang benar di database dulu (via php spark db:query), baru terjemahkan ke Query Builder. Query Builder menyembunyikan kompleksitas SQL, tapi kalian tetap harus tahu SQL yang ingin dihasilkan agar tidak menebak-nebak.
Untuk laporan, Query Builder menyediakan method aggregate:
$stats = $db->table('posts')
->select('categories.name AS kategori, COUNT(posts.id) AS jumlah')
->join('categories', 'categories.id = posts.category_id')
->groupBy('categories.id')
->get()
->getResultArray();
// [['kategori' => 'Tutorial', 'jumlah' => 12], ...]Method aggregate lain: selectAvg(), selectSum(), selectMax(), selectMin(), selectCount(). Tambahkan having() untuk memfilter hasil aggregate:
$db->table('posts')
->select('categories.name, COUNT(posts.id) AS jumlah')
->join('categories', 'categories.id = posts.category_id')
->groupBy('categories.id')
->having('jumlah > 5')
->get();Perbedaan where vs having: where memfilter baris sebelum di-group, having memfilter hasil setelah di-group.
Transaksi menjamin sekumpulan operasi berjalan all-or-nothing: jika satu gagal, semua dibatalkan. Bayangkan memindahkan uang: mengurangi saldo pengirim dan menambah saldo penerima harus terjadi bersama-sama.
$db = \Config\Database::connect();
$db->transStart();
$db->table('accounts')->where('id', $fromId)
->set('balance', 'balance - 100000', false)
->update();
$db->table('accounts')->where('id', $toId)
->set('balance', 'balance + 100000', false)
->update();
$db->transComplete();
if ($db->transStatus() === false) {
log_message('error', 'Transfer gagal: dari #{from} ke #{to}', [
'from' => $fromId,
'to' => $toId,
]);
return redirect()->back()->with('error', 'Transaksi gagal, tidak ada perubahan.');
}transStart() memulai, transComplete() menutup dan melakukan commit jika semua berhasil. transStatus() memberi tahu hasilnya.
Ada dua mode:
transStart/transComplete): jika query gagal, rollback otomatis dan transStatus() false.transBegin/transCommit/transRollback): commit/rollback manual, dan jika ingin error dilempar:$db = \Config\Database::connect();
$db->transBegin();
try {
$db->table('orders')->insert($order);
$db->table('order_items')->insertBatch($items);
$db->transCommit();
} catch (\Throwable $e) {
$db->transRollback();
log_message('error', 'Order gagal: {msg}', ['msg' => $e->getMessage()]);
}Warning
Operasi yang melibatkan banyak tabel yang saling bergantung (order + item, transfer + log) wajib transaksi. Tanpa transaksi, kegagalan di tengah operasi meninggalkan data setengah jadi — order tanpa item, atau saldo yang terpotong tapi tidak sampai tujuan.
CodeIgniter\Model menyediakan callbacks — method yang dipanggil otomatis sebelum/sesudah operasi. Contoh paling berguna: hash password sebelum insert, atau set slug otomatis.
<?php
namespace App\Models;
use CodeIgniter\Model;
class UserModel extends Model
{
protected $table = 'users';
protected $allowedFields = ['name', 'email', 'password', 'role'];
protected $useTimestamps = true;
protected $beforeInsert = ['hashPassword'];
protected function hashPassword(array $data): array
{
if (isset($data['data']['password'])) {
$data['data']['password'] = password_hash($data['data']['password'], PASSWORD_DEFAULT);
}
return $data;
}
}Callback yang tersedia: $beforeInsert, $afterInsert, $beforeUpdate, $afterUpdate, $beforeFind, $afterFind, $beforeDelete, $afterDelete. Setiap callback menerima dan mengembalikan array $data — jadi kalian bisa memodifikasi data sebelum diproses.
protected $beforeInsert = ['setSlug'];
protected function setSlug(array $data): array
{
if (isset($data['data']['title']) && empty($data['data']['slug'])) {
$data['data']['slug'] = (new \App\Libraries\Slugger())->make($data['data']['title']);
}
return $data;
}Logika bisnis (hash password, slug, timestamp) terkumpul di model, bukan berserakan di controller — inilah cara model menjadi "Eloquent-like" di CodeIgniter: tempat logika data tinggal.
Note
Callbacks model adalah kunci untuk meniru pola ORM modern (seperti Eloquent di Laravel) di CodeIgniter: observer events beforeInsert/afterInsert mirip konsep model events, tapi tetap ringan dan eksplisit. Ini filosofi CodeIgniter — kekuatan tanpa kompleksitas berlebihan.
Gabungkan semuanya — laporan penjualan per kategori dengan transaksi untuk mencatat laporan:
$db = \Config\Database::connect();
$db->transStart();
$report = $db->table('orders')
->select('categories.name, SUM(order_items.price * order_items.qty) AS total')
->join('order_items', 'order_items.order_id = orders.id')
->join('products', 'products.id = order_items.product_id')
->join('categories', 'categories.id = products.category_id')
->where('orders.status', 'paid')
->groupBy('categories.id')
->get()
->getResultArray();
$db->table('report_logs')->insert([
'generated_at' => date('Y-m-d H:i:s'),
'rows' => count($report),
]);
$db->transComplete();
if ($db->transStatus() === false) {
log_message('error', 'Laporan gagal dibuat');
return $this->fail('Terjadi kesalahan membuat laporan');
}
return $this->respond($report);join('table', 'condition', 'type') menggabungkan tabel; gunakan left untuk relasi opsional.getCompiledSelect(); aggregate via selectSum()/selectCount()/dll.groupBy() + having() untuk laporan berkelompok.transStart/transComplete/transStatus atau transBegin/transCommit/transRollback.beforeInsert, afterUpdate, dll) menampung logika otomatis di model.Inti yang harus dibawa pulang:
Di episode 22 selanjutnya kita akan membahas queue, cron & background jobs — membuat command custom di spark, mengotomasi dengan cron, dan memahami konsep queue untuk pekerjaan berat yang tidak bisa ditangani di request. Aplikasi kalian mulai bekerja di balik layar!