MySQL #
MySQL adalah salah satu database relasional paling populer di dunia, dan Ruby punya dukungan yang sangat matang untuk bekerja dengannya. Ada tiga lapisan utama untuk berinteraksi dengan MySQL dari Ruby: driver tingkat rendah mysql2 yang berkomunikasi langsung dengan server MySQL, query builder Sequel yang memberikan DSL Ruby yang ekspresif tanpa ORM penuh, dan ActiveRecord yang menjadi ORM standar Rails dengan abstraksi tertinggi. Memahami ketiga lapisan ini memungkinkan kamu memilih alat yang tepat untuk setiap situasi — dari skrip migrasi data sederhana hingga aplikasi Rails produksi yang melayani jutaan request.
Instalasi dan Konfigurasi #
Prerequisite — MySQL Client Library #
mysql2 gem membutuhkan MySQL client library terinstal di sistem sebelum bisa dikompilasi:
# Ubuntu / Debian
sudo apt install libmysqlclient-dev
# macOS dengan Homebrew
brew install mysql-client
# CentOS / RHEL / Fedora
sudo dnf install mysql-devel
Instalasi Gem #
# Driver MySQL murni — untuk akses langsung tanpa ORM
gem install mysql2
# Atau di Gemfile
# Gemfile
gem 'mysql2', '~> 0.5' # driver MySQL
# Opsional — query builder tanpa ORM penuh
gem 'sequel', '~> 5.75'
# Atau Rails dengan MySQL
gem 'rails', '~> 7.1'
gem 'mysql2', '~> 0.5'
Koneksi dengan mysql2 #
require 'mysql2'
# Buat koneksi
klien = Mysql2::Client.new(
host: "localhost",
port: 3306,
username: "root",
password: ENV["MYSQL_PASSWORD"],
database: "toko_db",
encoding: "utf8mb4", # penting untuk emoji dan karakter khusus
reconnect: true, # reconnect otomatis jika koneksi putus
connect_timeout: 10, # timeout koneksi dalam detik
read_timeout: 30, # timeout baca
write_timeout: 30, # timeout tulis
ssl_mode: :verify_identity # gunakan SSL di production
)
puts "MySQL versi: #{klien.server_info[:version]}"
# Selalu tutup koneksi setelah selesai
klien.close
Connection Pool — Untuk Multi-Thread #
Koneksi MySQL tidak thread-safe — setiap thread butuh koneksi sendiri. Gunakan connection pool untuk mengelolanya secara efisien:
require 'mysql2'
require 'connection_pool' # gem 'connection_pool'
# Buat pool dengan 5 koneksi maksimal
POOL = ConnectionPool.new(size: 5, timeout: 5) do
Mysql2::Client.new(
host: "localhost",
username: "root",
password: ENV["MYSQL_PASSWORD"],
database: "toko_db",
encoding: "utf8mb4",
reconnect: true
)
end
# Gunakan koneksi dari pool
POOL.with do |klien|
hasil = klien.query("SELECT COUNT(*) AS total FROM produk")
puts hasil.first["total"]
end
# Thread-safe — setiap thread dapat koneksi dari pool
10.times.map do
Thread.new do
POOL.with do |klien|
klien.query("SELECT SLEEP(0.1)")
end
end
end.each(&:join)
Query Dasar #
SELECT — Membaca Data #
require 'mysql2'
klien = Mysql2::Client.new(host: "localhost", username: "root",
password: ENV["MYSQL_PASSWORD"], database: "toko_db")
# Query sederhana — mengembalikan Mysql2::Result (Enumerable)
hasil = klien.query("SELECT * FROM produk LIMIT 10")
# Iterasi hasil
hasil.each do |baris|
puts "#{baris['id']}: #{baris['nama']} - Rp #{baris['harga']}"
end
# Konversi ke Array of Hash
produk = klien.query("SELECT id, nama, harga FROM produk WHERE aktif = 1").to_a
puts produk.first.inspect
# => {"id"=>1, "nama"=>"Laptop", "harga"=>15000000}
# Opsi symbolize_keys — key sebagai Symbol
hasil = klien.query("SELECT * FROM produk", symbolize_keys: true)
hasil.each { |b| puts b[:nama] }
# Opsi as: :array — setiap baris sebagai Array (lebih cepat)
hasil = klien.query("SELECT id, nama FROM produk", as: :array)
hasil.each { |b| puts b.inspect } # => [1, "Laptop"]
# Metadata kolom
puts hasil.fields.inspect # => ["id", "nama"]
# Satu baris saja
satu = klien.query("SELECT * FROM produk WHERE id = 1").first
puts satu["nama"] # => "Laptop"
Prepared Statement — Query yang Aman #
Prepared statement adalah cara yang benar untuk menyisipkan nilai variabel ke dalam query — mencegah SQL injection:
# ANTI-PATTERN: string interpolation — rentan SQL injection!
nama = params[:nama]
klien.query("SELECT * FROM produk WHERE nama = '#{nama}'")
# Jika nama = "'; DROP TABLE produk; --" → BENCANA!
# BENAR: prepared statement dengan placeholder
stmt = klien.prepare("SELECT * FROM produk WHERE nama = ? AND harga < ?")
hasil = stmt.execute("Laptop", 20_000_000)
hasil.each { |b| puts b.inspect }
stmt.close
# Prepared statement untuk INSERT
stmt = klien.prepare(
"INSERT INTO produk (nama, harga, stok, aktif) VALUES (?, ?, ?, ?)"
)
stmt.execute("Monitor", 3_500_000, 15, true)
new_id = klien.last_id
puts "Produk baru ID: #{new_id}"
stmt.close
# Prepared statement untuk UPDATE
stmt = klien.prepare("UPDATE produk SET harga = ?, stok = ? WHERE id = ?")
rows_affected = stmt.execute(3_200_000, 20, new_id)
puts "Baris diperbarui: #{klien.affected_rows}"
stmt.close
# Prepared statement untuk DELETE
stmt = klien.prepare("DELETE FROM produk WHERE id = ? AND stok = 0")
stmt.execute(new_id)
stmt.close
CRUD Lengkap #
class ProdukRepository
def initialize(klien)
@klien = klien
end
# CREATE
def buat(nama:, harga:, stok:, kategori_id:)
stmt = @klien.prepare(
"INSERT INTO produk (nama, harga, stok, kategori_id, aktif, created_at)
VALUES (?, ?, ?, ?, TRUE, NOW())"
)
stmt.execute(nama, harga, stok, kategori_id)
id_baru = @klien.last_id
stmt.close
temukan(id_baru)
end
# READ
def temukan(id)
stmt = @klien.prepare("SELECT * FROM produk WHERE id = ?")
hasil = stmt.execute(id)
baris = hasil.first
stmt.close
baris
end
def cari_semua(aktif: true, limit: 50, offset: 0)
stmt = @klien.prepare(
"SELECT p.*, k.nama AS kategori
FROM produk p
LEFT JOIN kategori k ON p.kategori_id = k.id
WHERE p.aktif = ?
ORDER BY p.created_at DESC
LIMIT ? OFFSET ?"
)
hasil = stmt.execute(aktif, limit, offset).to_a
stmt.close
hasil
end
def cari_berdasarkan_harga(maks:, min: 0)
stmt = @klien.prepare(
"SELECT * FROM produk
WHERE harga BETWEEN ? AND ? AND aktif = TRUE
ORDER BY harga ASC"
)
hasil = stmt.execute(min, maks).to_a
stmt.close
hasil
end
# UPDATE
def perbarui(id, atribut = {})
return false if atribut.empty?
kolom = atribut.keys.map { |k| "#{k} = ?" }.join(", ")
nilai = atribut.values + [id]
stmt = @klien.prepare(
"UPDATE produk SET #{kolom}, updated_at = NOW() WHERE id = ?"
)
stmt.execute(*nilai)
rows = @klien.affected_rows
stmt.close
rows > 0
end
# DELETE (soft delete — tidak benar-benar hapus)
def nonaktifkan(id)
stmt = @klien.prepare(
"UPDATE produk SET aktif = FALSE, updated_at = NOW() WHERE id = ?"
)
stmt.execute(id)
rows = @klien.affected_rows
stmt.close
rows > 0
end
# Hard delete jika benar-benar perlu
def hapus!(id)
stmt = @klien.prepare("DELETE FROM produk WHERE id = ?")
stmt.execute(id)
rows = @klien.affected_rows
stmt.close
rows > 0
end
end
Transaksi #
Transaksi memastikan serangkaian operasi berhasil semua atau gagal semua — atomik:
def transfer_stok(dari_id, ke_id, jumlah)
klien.query("START TRANSACTION")
begin
# Cek stok sumber mencukupi
stmt = klien.prepare("SELECT stok FROM produk WHERE id = ? FOR UPDATE")
sumber = stmt.execute(dari_id).first
stmt.close
raise "Stok tidak mencukupi" if sumber["stok"] < jumlah
# Kurangi stok sumber
stmt = klien.prepare("UPDATE produk SET stok = stok - ? WHERE id = ?")
stmt.execute(jumlah, dari_id)
stmt.close
# Tambah stok tujuan
stmt = klien.prepare("UPDATE produk SET stok = stok + ? WHERE id = ?")
stmt.execute(jumlah, ke_id)
stmt.close
klien.query("COMMIT")
true
rescue => e
klien.query("ROLLBACK")
puts "Transaksi dibatalkan: #{e.message}"
false
end
end
# Helper transaksi yang lebih bersih
def dengan_transaksi(klien)
klien.query("START TRANSACTION")
begin
hasil = yield
klien.query("COMMIT")
hasil
rescue => e
klien.query("ROLLBACK")
raise e
end
end
dengan_transaksi(klien) do
# semua operasi di sini
klien.query("UPDATE rekening SET saldo = saldo - 500000 WHERE id = 1")
klien.query("UPDATE rekening SET saldo = saldo + 500000 WHERE id = 2")
end
sequenceDiagram
participant App as Aplikasi Ruby
participant MySQL
App->>MySQL: START TRANSACTION
App->>MySQL: UPDATE stok sumber - 10
MySQL-->>App: OK (1 row affected)
App->>MySQL: UPDATE stok tujuan + 10
MySQL-->>App: OK (1 row affected)
App->>MySQL: COMMIT
MySQL-->>App: Query OK
note over App,MySQL: Jika ada error di tengah:
App->>MySQL: ROLLBACK
MySQL-->>App: Semua perubahan dibatalkanSequel — Query Builder yang Ekspresif #
Sequel adalah query builder Ruby yang duduk di antara driver murni dan ORM penuh. Ia menyediakan DSL yang ekspresif sambil memberi kontrol penuh atas SQL yang dihasilkan:
require 'sequel'
# Koneksi Sequel ke MySQL
DB = Sequel.connect(
adapter: "mysql2",
host: "localhost",
user: "root",
password: ENV["MYSQL_PASSWORD"],
database: "toko_db",
encoding: "utf8mb4",
max_connections: 10
)
# Dataset — lazy, belum execute query
produk = DB[:produk]
# SELECT
produk.all # SELECT * FROM produk
produk.where(aktif: true).all # WHERE aktif = 1
produk.where(harga: 100_000..5_000_000).all # WHERE harga BETWEEN ...
produk.where { harga > 1_000_000 }.all # WHERE harga > 1000000
produk.order(:harga).limit(10).all # ORDER BY harga LIMIT 10
produk.select(:id, :nama, :harga).all # SELECT id, nama, harga
# JOIN
DB[:produk]
.join(:kategori, id: :kategori_id)
.select(Sequel[:produk][:nama], Sequel[:kategori][:nama].as(:kategori))
.where(Sequel[:produk][:aktif] => true)
.all
# Agregasi
produk.count # SELECT COUNT(*) FROM produk
produk.where(aktif: true).sum(:harga) # SUM(harga)
produk.group(:kategori_id).select(:kategori_id, Sequel.function(:count).as(:total)).all
# INSERT
DB[:produk].insert(
nama: "Monitor",
harga: 3_500_000,
stok: 15,
kategori_id: 2,
aktif: true,
created_at: Time.now
)
new_id = DB.last_insert_id
# UPDATE
DB[:produk].where(id: new_id).update(harga: 3_200_000, updated_at: Time.now)
# DELETE
DB[:produk].where(id: new_id).delete
# Transaksi dengan Sequel
DB.transaction do
DB[:rekening].where(id: 1).update(saldo: Sequel[:saldo] - 500_000)
DB[:rekening].where(id: 2).update(saldo: Sequel[:saldo] + 500_000)
# Jika raise exception → ROLLBACK otomatis
end
# Raw SQL ketika dibutuhkan
DB.fetch("SELECT * FROM produk WHERE nama LIKE ?", "%Laptop%").all
DB.run("ALTER TABLE produk ADD INDEX idx_harga (harga)")
ActiveRecord dengan MySQL di Rails #
ActiveRecord adalah ORM yang digunakan Rails. Ia mengabstraksikan SQL sepenuhnya menggunakan model Ruby:
Konfigurasi database.yml #
# config/database.yml
default: &default
adapter: mysql2
encoding: utf8mb4
collation: utf8mb4_unicode_ci
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: <%= ENV["DB_USERNAME"] || "root" %>
password: <%= ENV["DB_PASSWORD"] %>
host: <%= ENV["DB_HOST"] || "localhost" %>
port: <%= ENV["DB_PORT"] || 3306 %>
socket: /tmp/mysql.sock
reconnect: true
development:
<<: *default
database: toko_development
test:
<<: *default
database: toko_test
production:
<<: *default
database: <%= ENV["DB_NAME"] %>
host: <%= ENV["DB_HOST"] %>
Model ActiveRecord #
# app/models/produk.rb
class Produk < ApplicationRecord
belongs_to :kategori
has_many :item_pesanan
has_many :pesanan, through: :item_pesanan
# Validasi
validates :nama, presence: true, length: { minimum: 2, maximum: 200 }
validates :harga, numericality: { greater_than: 0 }
validates :stok, numericality: { greater_than_or_equal_to: 0 }
# Scope
scope :aktif, -> { where(aktif: true) }
scope :terjangkau, -> { where("harga < ?", 1_000_000) }
scope :terbaru, -> { order(created_at: :desc) }
scope :dalam_harga, ->(min, maks) { where(harga: min..maks) }
# Callbacks
before_save :normalisasi_nama
after_create :catat_log_baru
def tersedia?
stok > 0 && aktif?
end
def harga_format
"Rp #{harga.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse}"
end
private
def normalisasi_nama
self.nama = nama.strip.squeeze(" ")
end
def catat_log_baru
Rails.logger.info "Produk baru dibuat: #{nama} (ID: #{id})"
end
end
# Query dengan ActiveRecord
Produk.all # SELECT * FROM produk
Produk.aktif # WHERE aktif = 1
Produk.aktif.terbaru.limit(10) # WHERE aktif=1 ORDER BY created_at DESC LIMIT 10
Produk.aktif.dalam_harga(100_000, 5_000_000) # WHERE aktif=1 AND harga BETWEEN ...
Produk.where(kategori_id: [1, 2, 3]) # WHERE kategori_id IN (1,2,3)
Produk.where("nama LIKE ?", "%#{kata_kunci}%") # LIKE dengan parameterized
Produk.includes(:kategori).aktif # LEFT OUTER JOIN — eager loading
Produk.joins(:kategori).where(kategori: { nama: "Elektronik" }) # INNER JOIN
# Agregasi
Produk.aktif.count # SELECT COUNT(*) ...
Produk.aktif.sum(:harga) # SELECT SUM(harga) ...
Produk.aktif.average(:harga) # SELECT AVG(harga) ...
Produk.aktif.maximum(:harga) # SELECT MAX(harga) ...
Produk.aktif.minimum(:harga) # SELECT MIN(harga) ...
Produk.group(:kategori_id).count # GROUP BY dengan count
# Pagination sederhana
halaman = 1
per_halaman = 20
Produk.aktif.limit(per_halaman).offset((halaman - 1) * per_halaman)
Migrasi Schema #
# db/migrate/20240815000000_buat_tabel_produk.rb
class BuatTabelProduk < ActiveRecord::Migration[7.1]
def change
create_table :produk do |t|
t.string :nama, null: false, limit: 200
t.text :deskripsi
t.decimal :harga, null: false, precision: 15, scale: 2
t.integer :stok, null: false, default: 0
t.boolean :aktif, null: false, default: true
t.references :kategori, null: false, foreign_key: true
t.string :sku, limit: 50, unique: true
t.string :gambar_url
t.timestamps # created_at dan updated_at otomatis
end
# Index untuk kolom yang sering diquery
add_index :produk, :aktif
add_index :produk, :harga
add_index :produk, [:kategori_id, :aktif] # composite index
add_index :produk, :sku, unique: true
add_index :produk, :nama, type: :fulltext # MySQL FULLTEXT index
end
end
# Jalankan migrasi
# rails db:migrate
# rails db:rollback # undo migrasi terakhir
# rails db:migrate:status # cek status semua migrasi
Indexing dan Optimasi Query #
Index adalah kunci performa MySQL. Tanpa index yang tepat, query pada tabel besar bisa membutuhkan waktu menit, bukan milidetik:
-- Tanpa index: full table scan
EXPLAIN SELECT * FROM produk WHERE harga < 1000000;
-- type: ALL → membaca seluruh tabel (lambat!)
-- Setelah add_index :produk, :harga
EXPLAIN SELECT * FROM produk WHERE harga < 1000000;
-- type: range → hanya baca baris yang relevan (cepat!)
# Menganalisis query lambat di Rails
# Aktifkan query logging di development
# config/environments/development.rb:
# config.log_level = :debug
# Gunakan EXPLAIN untuk menganalisis query
ActiveRecord::Base.connection.execute(
"EXPLAIN SELECT * FROM produk WHERE kategori_id = 1 AND aktif = 1"
).each { |row| puts row.inspect }
# Atau dengan Bullet gem untuk mendeteksi N+1 query
# gem 'bullet'
# Bullet akan mengingatkan jika ada query N+1 yang bisa dihindari dengan includes
# Eager loading untuk menghindari N+1
# ANTI-PATTERN: N+1 query
pesanan = Pesanan.limit(10)
pesanan.each { |p| puts p.pengguna.nama } # 1 query + 10 query = 11 query!
# BENAR: eager loading
pesanan = Pesanan.includes(:pengguna).limit(10)
pesanan.each { |p| puts p.pengguna.nama } # hanya 2 query
Batch Processing — Data Besar #
Untuk memproses jutaan baris tanpa kehabisan memori:
# ANTI-PATTERN: memuat semua data ke memori
Produk.all.each { |p| proses(p) } # bisa crash jika jutaan baris!
# BENAR: find_each — batch per 1000 baris
Produk.aktif.find_each(batch_size: 1000) do |produk|
proses(produk)
end
# find_in_batches — dapatkan batch sebagai array
Produk.aktif.find_in_batches(batch_size: 500) do |batch|
# batch adalah Array of 500 Produk
# cocok untuk bulk insert atau bulk update
ids = batch.map(&:id)
puts "Memproses batch dengan #{batch.size} produk, ID #{ids.first}-#{ids.last}"
end
# in_batches — lebih fleksibel, kembalikan relation
Produk.aktif.in_batches(of: 1000) do |batch_relation|
batch_relation.update_all(diperbarui_batch: true) # satu UPDATE per batch
end
# Raw SQL batch untuk performa maximum
offset = 0
batch_size = 1000
loop do
baris = klien.query(
"SELECT * FROM produk WHERE aktif = 1 LIMIT #{batch_size} OFFSET #{offset}"
).to_a
break if baris.empty?
proses_batch(baris)
offset += batch_size
puts "Diproses: #{offset} baris"
end
Keamanan — SQL Injection Prevention #
# SQL INJECTION — contoh serangan
# Input dari user: "'; DROP TABLE produk; --"
# RENTAN — JANGAN LAKUKAN INI
nama_input = params[:nama]
klien.query("SELECT * FROM produk WHERE nama = '#{nama_input}'")
# Query menjadi: SELECT * FROM produk WHERE nama = ''; DROP TABLE produk; --'
# AMAN 1: Prepared statement (mysql2)
stmt = klien.prepare("SELECT * FROM produk WHERE nama = ?")
stmt.execute(nama_input)
# AMAN 2: Escape manual (kurang direkomendasikan)
nama_aman = klien.escape(nama_input)
klien.query("SELECT * FROM produk WHERE nama = '#{nama_aman}'")
# AMAN 3: ActiveRecord parameterized query
Produk.where("nama = ?", nama_input)
Produk.where(nama: nama_input)
# AMAN 4: Sequel parameterized
DB[:produk].where(nama: nama_input)
DB[:produk].where(Sequel.lit("nama = ?", nama_input))
# ANTI-PATTERN di ActiveRecord — masih rentan!
Produk.where("nama = '#{params[:nama]}'") # JANGAN!
Produk.order(params[:kolom]) # JANGAN! Bisa inject ORDER BY
Ringkasan #
- Selalu gunakan prepared statement —
stmt = klien.prepare("... WHERE id = ?"); stmt.execute(id)mencegah SQL injection; jangan pernah interpolasi variabel langsung ke string SQL.- Connection pool untuk multi-thread — satu koneksi MySQL tidak thread-safe; gunakan
ConnectionPoolatau konfigurasi pool ActiveRecord agar setiap thread dapat koneksi sendiri.utf8mb4bukanutf8—utf8di MySQL hanya mendukung 3 byte per karakter (tidak bisa menyimpan emoji);utf8mb4mendukung 4 byte dan adalah standar yang benar.- Transaksi untuk operasi yang saling bergantung —
START TRANSACTION, lakukan semua operasi,COMMITjika sukses atauROLLBACKjika ada yang gagal.- Index pada kolom yang sering di-WHERE, JOIN, ORDER — tanpa index, MySQL melakukan full table scan yang sangat lambat pada tabel besar.
EXPLAINuntuk debug query lambat —type: ALLberarti full table scan;type: refatautype: rangeberarti index digunakan.find_eachuntuk data besar — janganProduk.all.eachuntuk jutaan baris; gunakanfind_each(batch_size: 1000)agar memori tidak meledak.- Eager loading dengan
includes— hindari N+1 query denganProduk.includes(:kategori)alih-alih mengaksesproduk.kategoridi dalam loop.- Sequel untuk kontrol SQL lebih presisi — pilih Sequel jika butuh query kompleks tapi tidak mau full Rails ORM; ia menghasilkan SQL yang lebih transparan dari ActiveRecord.
- Jangan hardcode kredensial — gunakan environment variable di
database.yml:password: <%= ENV["DB_PASSWORD"] %>.