Membedah Active Record tingkat lanjut: query optimization dengan select dan group, agregasi, raw SQL yang aman, composite primary keys Rails 8.1, serta praktik reporting query dengan indexing

Setelah di episode 12 kalian mengoptimasi query dasar, episode 21 membawa Active Record ke tingkat berikutnya: query yang kompleks dan hemat. Di sinilah kalian berpindah dari "mengambil data" menjadi "menyuruh database bekerja" — agregasi, grouping, dan reporting langsung di level database.
Mengapa episode ini penting? Karena aplikasi yang bertumbuh tidak bisa lagi memuat semua baris ke Ruby untuk dihitung. Menghitung 10 ribu komentar di Ruby memakan memori; menghitungnya di database butuh milidetik. Menguasai agregasi AR berarti kalian bisa membangun dashboard, laporan, dan analytics tanpa gem tambahan — dan tahu kapan harus turun ke raw SQL.
select membatasi kolom yang diambil — hemat transfer dan memori, dan memungkinkan agregasi:
Post.select(:id, :title)
# SELECT "posts"."id", "posts"."title" FROM "posts"Kunci yang sering luput: model yang di-select sebagian tidak bisa mengakses kolom lain — post.body pada hasil di atas akan error. Pakai select untuk data ringan (list, dropdown) dan hindari mengakses kolom yang tidak di-select.
Untuk mengambil satu kolom sebagai array:
Post.pluck(:title) # satu query, array string
Post.all.map(&:title) # memuat objek penuh dulu — lebih lambat & borospluck menghindari instansiasi model — jauh lebih efisien untuk data sederhana.
Menghitung komentar per post:
Comment.group(:post_id).count
# SELECT post_id, COUNT(*) FROM comments GROUP BY post_id
# => { 1 => 3, 2 => 7, ... }Mengelompokkan dengan kolom dari relasi lain:
Post.joins(:user).group("users.name").count
# SELECT users.name, COUNT(*) FROM posts JOIN users ... GROUP BY users.nameHasilnya hash nama user => jumlah post. Ini pattern inti reporting: hitung di database, bukan di Ruby.
Order.sum(:total)
Order.average(:total)
Order.maximum(:total)
Order.minimum(:total)Semua dieksekusi sebagai satu query SQL — tidak ada data yang dimuat ke memori.
Mari bangun laporan "post per bulan per status":
Post
.group("strftime('%Y-%m', created_at)")
.count
# => { "2026-06" => 12, "2026-07" => 19, ... }Untuk distribusi status:
Post.group(:status).count
# => { "draft" => 8, "published" => 34, "archived" => 2 }Menggabungkan beberapa metrik dalam satu query:
Post.joins(:user)
.group("users.id")
.select("users.id, COUNT(posts.id) AS post_count, AVG(posts.views) AS avg_views")
.order("post_count DESC")Semua query di atas menjadi lambat tanpa index pada kolom yang di-group/filter. Index membuat database mencari baris tanpa memindai seluruh tabel:
class AddIndexesForReporting < ActiveRecord::Migration[8.1]
def change
add_index :posts, :status
add_index :posts, :created_at
end
endAturan praktis indexing:
where, order, group, join.add_index :comments, [:post_id, :created_at].EXPLAIN: Post.where(status: :published).explain menampilkan plan query dan penggunaan index.EXPLAIN for: SELECT "posts".* FROM "posts" WHERE "posts"."status" = ?
QUERY PLAN
Bitmap Heap Scan on posts (cost=... rows=...)
Recheck Cond: (status = 'published'::text)Kadang AR tidak cukup ekspresif. Dua cara turun ke SQL tanpa membuka celah:
Post.find_by_sql([<<~SQL, "published"])
SELECT p.id, p.title, COUNT(c.id) AS comments_count
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
WHERE p.status = ?
GROUP BY p.id
SQLPost.where("posts.title ILIKE ?", "%#{Post.sanitize_sql_like(params[:q])}%")Aturan emas tetap berlaku: semua input user harus lewat ? placeholder — termasuk di find_by_sql. sanitize_sql_like digunakan saat user input masuk ke pola LIKE (untuk meng-escape %/_).
Rails 8.1 menambahkan dukungan penuh composite primary keys — tabel dengan primary key multi-kolom, umum di database legacy atau tabel join:
class JoinRecord < ApplicationRecord
self.primary_key = %i[user_id group_id]
endDengan dukungan ini, AR bisa memakai tabel yang PRIMARY KEY (user_id, group_id) tanpa kolom id sintetik — berguna saat mengintegrasikan schema warisan atau tabel yang didesain tanpa single PK.
map(&:attribute) alih-alih pluck — boros memori; gunakan pluck.select — NoMethodError membingungkan; pastikan semua kolom yang dipakai di-select.comments.all.count vs comments.count; selalu count di database.group tanpa index — query lambat; index kolom group/filter.? placeholder.Episode 21 membekali kalian Active Record tingkat lanjut: select/pluck untuk data ringan, group/count/sum untuk agregasi di database, reporting query dengan index yang tepat, raw SQL yang aman dengan placeholder, dan composite primary keys Rails 8.1 untuk schema legacy.
Inti yang harus dibawa pulang:
select membatasi kolom; pluck menghindari instansiasi model.group(:x).count menghitung di database — bukan di Ruby.explain.? placeholder + sanitize_sql_like.self.primary_key = %i[...] untuk tabel legacy.Di episode 22 selanjutnya kita akan membedah background jobs scale & Solid Stack — Solid Queue, Solid Cache, Solid Cable sebagai stack database-driven tanpa Redis, dan produksi dengan Solid Stack. Sampai jumpa di episode 22!