MySQL

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 dibatalkan

Sequel — 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 statementstmt = 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 ConnectionPool atau konfigurasi pool ActiveRecord agar setiap thread dapat koneksi sendiri.
  • utf8mb4 bukan utf8utf8 di MySQL hanya mendukung 3 byte per karakter (tidak bisa menyimpan emoji); utf8mb4 mendukung 4 byte dan adalah standar yang benar.
  • Transaksi untuk operasi yang saling bergantungSTART TRANSACTION, lakukan semua operasi, COMMIT jika sukses atau ROLLBACK jika 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.
  • EXPLAIN untuk debug query lambattype: ALL berarti full table scan; type: ref atau type: range berarti index digunakan.
  • find_each untuk data besar — jangan Produk.all.each untuk jutaan baris; gunakan find_each(batch_size: 1000) agar memori tidak meledak.
  • Eager loading dengan includes — hindari N+1 query dengan Produk.includes(:kategori) alih-alih mengakses produk.kategori di 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"] %>.

← Sebelumnya: YAML   Berikutnya: MSSQL →

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