ORM Adapter

ORM Adapter #

ORM (Object-Relational Mapper) adalah lapisan abstraksi yang memetakan baris di database menjadi objek Ruby — kamu bekerja dengan objek dan method Ruby, bukan SQL mentah. Ruby punya ekosistem ORM yang kaya: ActiveRecord yang terintegrasi penuh dengan Rails dan sangat produktif, Sequel yang lebih eksplisit dan fleksibel, ROM (Ruby Object Mapper) yang mengambil pendekatan fungsional dengan pemisahan yang ketat antara persistence dan domain model, hingga Mongoid untuk MongoDB dan Ohm untuk Redis. Memahami trade-off setiap ORM — antara kemudahan penggunaan, kontrol SQL, testability, dan arsitektur — adalah keterampilan yang membedakan developer Ruby yang baik.

Perbandingan ORM Ruby #

flowchart TD
    A[Kebutuhan Persistence] --> B{Jenis Database?}
    B --> C["Relasional\nPostgreSQL/MySQL/SQLite"]
    B --> D["MongoDB"]
    B --> E["Redis"]
    C --> F{Prioritas?}
    F --> G["Produktivitas\n& Rails Integration\n→ ActiveRecord"]
    F --> H["Kontrol SQL\n& Fleksibilitas\n→ Sequel"]
    F --> I["Arsitektur Bersih\n& Domain-Driven\n→ ROM / Hanami::DB"]
    D --> J["Mongoid\natau mongo driver"]
    E --> K["Ohm\natau redis-objects"]
ORM           Paradigma       Cocok untuk
──────────────────────────────────────────────────────────
ActiveRecord  Active Record   Rails apps, rapid development
Sequel        Query Builder   Complex SQL, non-Rails apps
ROM           Data Mapper     DDD, clean architecture, testability
Mongoid       ODM (MongoDB)   Document database
Ohm           Hash Model      Redis-backed simple objects

ActiveRecord — ORM Default Rails #

ActiveRecord mengimplementasikan pola Active Record: model adalah objek yang tahu cara menyimpan dan membaca dirinya sendiri dari database. Ini sangat produktif tapi mencampur domain logic dengan persistence concern.

# Gemfile
# gem 'activerecord', '~> 7.1'
# gem 'pg'   # atau mysql2, sqlite3

require 'active_record'

ActiveRecord::Base.establish_connection(
  adapter:  "postgresql",
  host:     "localhost",
  database: "toko_db",
  username: "appuser",
  password: ENV["DB_PASSWORD"]
)

# Model ActiveRecord
class Produk < ActiveRecord::Base
  belongs_to :kategori
  has_many   :item_pesanan
  has_many   :pesanan, through: :item_pesanan

  validates :nama,  presence: true, length: { minimum: 2 }
  validates :harga, numericality: { greater_than: 0 }

  scope :aktif,   -> { where(aktif: true) }
  scope :terbaru, -> { order(created_at: :desc) }

  before_save :normalisasi_nama

  private

  def normalisasi_nama
    self.nama = nama.strip if nama
  end
end

# Query — sangat ekspresif
Produk.aktif.terbaru.limit(10)
Produk.where("harga BETWEEN ? AND ?", 100_000, 5_000_000)
Produk.includes(:kategori).where(kategori: { nama: "Elektronik" })

# CRUD
produk = Produk.create!(nama: "Laptop", harga: 15_000_000)
produk.update!(harga: 14_500_000)
produk.destroy

Kekuatan dan Kelemahan ActiveRecord #

Kekuatan ActiveRecord:
  ✓ Sangat produktif — sedikit kode untuk banyak fungsionalitas
  ✓ Terintegrasi sempurna dengan Rails (mailer, cache, job)
  ✓ Ekosistem besar — Devise, PaperTrail, Ransack, dll.
  ✓ Migrations yang elegan dan mudah
  ✓ Callback lifecycle yang kaya

Kelemahan ActiveRecord:
  ✗ Model "fat" — domain logic dan persistence logic bercampur
  ✗ Coupling ke database — sulit mock/test tanpa database
  ✗ N+1 query jika tidak hati-hati
  ✗ Callback yang kompleks bisa menyebabkan bug yang sulit dilacak
  ✗ Kurang cocok untuk arsitektur yang memisahkan domain dari infrastruktur

Sequel — Query Builder yang Eksplisit #

Sequel memberikan kontrol penuh atas SQL yang dihasilkan sambil tetap menyediakan DSL Ruby yang ekspresif. Ia lebih eksplisit dari ActiveRecord — kamu tahu persis SQL apa yang dieksekusi.

# Gemfile
# gem 'sequel', '~> 5.75'
# gem 'pg'

require 'sequel'

DB = Sequel.connect(
  adapter:  "postgres",
  host:     "localhost",
  database: "toko_db",
  user:     "appuser",
  password: ENV["DB_PASSWORD"],
  max_connections: 10
)

# Dataset — lazy query builder
produk = DB[:produk]

# Query dasar
produk.where(aktif: true).order(Sequel.desc(:created_at)).limit(10).all
produk.where { harga > 1_000_000 }.all
produk.where(kategori_id: [1, 2, 3]).all

# JOIN — Sequel menghasilkan SQL yang sangat tepat
DB[:produk]
  .join(:kategori, id: :kategori_id)
  .select(
    Sequel[:produk][:id],
    Sequel[:produk][:nama],
    Sequel[:produk][:harga],
    Sequel[:kategori][:nama].as(:kategori_nama)
  )
  .where(Sequel[:produk][:aktif] => true)
  .all

# Agregasi
produk.where(aktif: true).count
produk.group(:kategori_id).select(:kategori_id, Sequel.function(:count).as(:total)).all

# INSERT dengan RETURNING
id_baru = DB[:produk].returning(:id).insert(
  nama:       "Monitor 4K",
  harga:      5_500_000,
  aktif:      true,
  created_at: Time.now
)

# UPDATE
DB[:produk].where(id: id_baru).update(harga: 5_200_000)

# DELETE
DB[:produk].where(id: id_baru).delete

# Raw SQL ketika dibutuhkan
DB.fetch("SELECT * FROM produk WHERE nama ILIKE ?", "%laptop%").all

Model Sequel dengan Sequel::Model #

require 'sequel'

class Produk < Sequel::Model
  # Asosiasi
  many_to_one :kategori
  one_to_many :item_pesanan
  many_to_many :pesanan, join_table: :item_pesanan

  # Validasi (berbeda sintaks dari ActiveRecord)
  plugin :validation_helpers

  def validate
    super
    validates_presence   [:nama, :harga]
    validates_min_length 2, :nama
    validates_numeric    :harga, greater_than: 0
    validates_unique     :sku, allow_nil: true
  end

  # Plugin — fitur opsional yang di-opt-in
  plugin :timestamps, update_on_create: true
  plugin :soft_deletes   # soft delete bawaan
  plugin :pagination     # untuk pagination

  dataset_module do
    def aktif
      where(aktif: true)
    end

    def terbaru
      order(Sequel.desc(:created_at))
    end

    def dalam_harga(min, maks)
      where(harga: min..maks)
    end
  end
end

# Penggunaan
Produk.aktif.terbaru.paginate(1, 20).all
Produk.aktif.dalam_harga(500_000, 5_000_000).all

# Hook (seperti callback di ActiveRecord)
class Produk < Sequel::Model
  def before_save
    self.nama = nama&.strip
    super
  end

  def after_create
    Rails.logger.info "Produk baru: #{nama}"
    super
  end
end

ROM — Ruby Object Mapper #

ROM mengimplementasikan pola Data Mapper yang memisahkan domain object dari persistence logic secara ketat. Lebih verbose dari ActiveRecord tapi menghasilkan kode yang lebih bersih dan mudah ditest.

# Gemfile
# gem 'rom',           '~> 5.3'
# gem 'rom-sql',       '~> 3.6'
# gem 'rom-repository','~> 2.3'
# gem 'pg'

require 'rom'
require 'rom-sql'
require 'rom-repository'

# Konfigurasi ROM
config = ROM::Configuration.new(:sql, 'postgresql://localhost/toko_db')

# Relation — mendefinisikan struktur data dan query
config.relation(:produk) do
  schema(infer: true) do
    associations do
      belongs_to :kategori
      has_many   :item_pesanan
    end
  end

  def aktif
    where(aktif: true)
  end

  def terbaru
    order(self.class.schema[:created_at].desc)
  end

  def dengan_kategori
    join(:kategori)
  end
end

# Repository — interface untuk mengakses data
class ProdukRepository < ROM::Repository[:produk]
  commands :create, update: :by_pk, delete: :by_pk

  def semua_aktif
    produk.aktif.terbaru.to_a
  end

  def temukan(id)
    produk.by_pk(id).one!
  end

  def cari(kata_kunci)
    produk.where { nama.ilike("%#{kata_kunci}%") }.to_a
  end

  def dengan_kategori(id)
    produk.dengan_kategori.by_pk(id).one
  end
end

# Container — dependency injection container
container = ROM.container(config)

# Penggunaan
repo = ProdukRepository.new(container)
semua = repo.semua_aktif
produk = repo.temukan(1)

# Create
repo.produk.command(:create).call(
  nama:       "Laptop Gaming",
  harga:      22_000_000,
  stok:       5,
  aktif:      true,
  created_at: Time.now
)

Struct sebagai Domain Object di ROM #

# ROM mengembalikan struct (bukan ActiveRecord object)
# Ini memungkinkan test tanpa database

require 'dry-struct'
require 'dry-types'

module Types
  include Dry.Types()
end

# Domain entity — murni Ruby object, tidak tahu tentang database
class Produk < Dry::Struct
  attribute :id,          Types::Integer
  attribute :nama,        Types::String
  attribute :harga,       Types::Decimal
  attribute :stok,        Types::Integer
  attribute :aktif,       Types::Bool
  attribute :created_at,  Types::Time

  def tersedia?
    aktif && stok > 0
  end

  def harga_format
    "Rp #{harga.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse}"
  end
end

# Sekarang domain logic bisa ditest tanpa database sama sekali:
produk = Produk.new(id: 1, nama: "Test", harga: 10_000, stok: 5, aktif: true, created_at: Time.now)
puts produk.tersedia?   # => true (tidak butuh database!)

Hanami::DB — ORM untuk Hanami Framework #

Hanami menggunakan ROM di balik layar dengan integrasi yang lebih seamless:

# Gemfile (dalam konteks Hanami)
# gem 'hanami',    '~> 2.1'
# gem 'hanami-db', '~> 2.1'
# gem 'rom-sql'

# slices/main/persistence/relations/produk.rb
module Main
  module Persistence
    module Relations
      class Produk < Hanami::DB::Relation
        schema(:produk, infer: true) do
          associations do
            belongs_to :kategori
          end
        end

        def aktif
          where(aktif: true)
        end
      end
    end
  end
end

# slices/main/repositories/produk_repository.rb
module Main
  module Repositories
    class ProdukRepository < Hanami::DB::Repository
      def semua_aktif
        produk.aktif.to_a
      end

      def temukan(id)
        produk.by_pk(id).one
      end
    end
  end
end

Mongoid — ODM untuk MongoDB #

Mongoid menyediakan pengalaman mirip ActiveRecord untuk MongoDB — dengan embedded documents dan flexible schema:

# Gemfile
# gem 'mongoid', '~> 8.1'

require 'mongoid'

Mongoid.load!("config/mongoid.yml")

class Produk
  include Mongoid::Document
  include Mongoid::Timestamps

  field :nama,        type: String
  field :harga,       type: BigDecimal
  field :stok,        type: Integer, default: 0
  field :aktif,       type: Boolean, default: true
  field :tag,         type: Array,   default: []

  embeds_one  :spesifikasi
  embeds_many :gambar
  belongs_to  :kategori, optional: true

  index({ nama: "text" })
  index({ aktif: 1, created_at: -1 })

  validates :nama,  presence: true
  validates :harga, numericality: { greater_than: 0 }

  scope :aktif,     -> { where(aktif: true) }
  scope :terbaru,   -> { order(created_at: :desc) }
  scope :dengan_tag, ->(t) { where(tag: t) }

  def tersedia?
    aktif && stok > 0
  end
end

class Spesifikasi
  include Mongoid::Document
  embedded_in :produk

  field :prosesor, type: String
  field :ram,      type: String
  field :storage,  type: String
end

# Penggunaan
produk = Produk.create!(
  nama: "Laptop Gaming Pro",
  harga: 22_000_000,
  tag: ["laptop", "gaming"]
)

produk.build_spesifikasi(prosesor: "Intel i9", ram: "32GB")
produk.save!

Produk.aktif.terbaru.limit(10)
Produk.dengan_tag("gaming")
Produk.where(:harga.gt => 10_000_000)

Ohm — Model Sederhana di atas Redis #

Ohm menyediakan model Redis yang sangat ringan — cocok untuk data yang butuh akses super cepat:

# Gemfile
# gem 'ohm', '~> 3.2'

require 'ohm'

Ohm.connect(url: ENV.fetch("REDIS_URL", "redis://localhost:6379"))

class Sesi < Ohm::Model
  attribute :user_id
  attribute :token
  attribute :expires_at
  attribute :ip_address

  index :user_id
  index :token

  def self.temukan_by_token(token)
    find(token: token).first
  end

  def kadaluarsa?
    Time.parse(expires_at) < Time.now
  end
end

# Ohm menyimpan ke Redis dengan key otomatis
sesi = Sesi.create(
  user_id:    "1",
  token:      SecureRandom.hex(32),
  expires_at: (Time.now + 3600).iso8601,
  ip_address: "192.168.1.1"
)

puts sesi.id   # => Redis key

# Cari
ditemukan = Sesi.temukan_by_token(sesi.token)
puts ditemukan.user_id   # => "1"

Pola Repository Pattern #

Repository Pattern memisahkan domain logic dari persistence logic — domain object tidak tahu cara menyimpan diri sendiri:

# Interface repository (abstrak)
module ProdukRepositoryInterface
  def temukan(id) = raise NotImplementedError
  def semua(filter: {}) = raise NotImplementedError
  def simpan(produk) = raise NotImplementedError
  def hapus(id) = raise NotImplementedError
end

# Implementasi dengan ActiveRecord
class ActiveRecordProdukRepository
  include ProdukRepositoryInterface

  def temukan(id)
    record = ProdukRecord.find(id)
    ke_domain(record)
  rescue ActiveRecord::RecordNotFound
    nil
  end

  def semua(filter: {})
    query = ProdukRecord.all
    query = query.where(aktif: filter[:aktif]) if filter.key?(:aktif)
    query = query.where("harga <= ?", filter[:maks_harga]) if filter[:maks_harga]
    query.map { |r| ke_domain(r) }
  end

  def simpan(produk)
    record = produk.id ? ProdukRecord.find(produk.id) : ProdukRecord.new
    record.assign_attributes(
      nama:       produk.nama,
      harga:      produk.harga,
      stok:       produk.stok,
      aktif:      produk.aktif,
      kategori_id: produk.kategori_id
    )
    record.save!
    ke_domain(record)
  end

  def hapus(id)
    ProdukRecord.find(id).destroy
    true
  rescue ActiveRecord::RecordNotFound
    false
  end

  private

  # Konversi dari ActiveRecord record ke domain object
  def ke_domain(record)
    ProdukDomain.new(
      id:          record.id,
      nama:        record.nama,
      harga:       record.harga,
      stok:        record.stok,
      aktif:       record.aktif,
      kategori_id: record.kategori_id,
      created_at:  record.created_at
    )
  end
end

# Domain object murni — tidak tahu tentang database
class ProdukDomain
  attr_reader :id, :nama, :harga, :stok, :aktif, :kategori_id, :created_at

  def initialize(id: nil, nama:, harga:, stok: 0, aktif: true, kategori_id:, created_at: nil)
    @id          = id
    @nama        = nama
    @harga       = harga
    @stok        = stok
    @aktif       = aktif
    @kategori_id = kategori_id
    @created_at  = created_at
  end

  def tersedia?
    @aktif && @stok > 0
  end

  def terapkan_diskon(persen)
    raise ArgumentError, "Diskon harus 0-100%" unless (0..100).include?(persen)
    @harga = @harga * (1 - persen / 100.0)
    self
  end
end

# Implementasi alternatif — in-memory untuk testing
class InMemoryProdukRepository
  include ProdukRepositoryInterface

  def initialize
    @store   = {}
    @next_id = 1
  end

  def temukan(id)
    @store[id]
  end

  def semua(filter: {})
    result = @store.values
    result = result.select { |p| p.aktif == filter[:aktif] } if filter.key?(:aktif)
    result
  end

  def simpan(produk)
    if produk.id.nil?
      produk = ProdukDomain.new(**produk.to_h.merge(id: @next_id))
      @next_id += 1
    end
    @store[produk.id] = produk
    produk
  end

  def hapus(id)
    !@store.delete(id).nil?
  end
end

# Penggunaan — bergantung pada interface, bukan implementasi
class ProdukService
  def initialize(repository)
    @repo = repository
  end

  def tampilkan_tersedia
    @repo.semua(filter: { aktif: true }).select(&:tersedia?)
  end

  def buat(nama:, harga:, kategori_id:)
    produk = ProdukDomain.new(nama: nama, harga: harga, kategori_id: kategori_id)
    @repo.simpan(produk)
  end
end

# Di production
service = ProdukService.new(ActiveRecordProdukRepository.new)

# Di test — tidak butuh database sama sekali!
service = ProdukService.new(InMemoryProdukRepository.new)

Query Object — Enkapsulasi Query Kompleks #

Query Object memindahkan query yang kompleks keluar dari model:

# app/queries/produk_tersedia_query.rb
class ProdukTersediaQuery
  def initialize(relation = Produk.all)
    @relation = relation
  end

  def call(kategori_id: nil, min_harga: nil, maks_harga: nil,
           kata_kunci: nil, urut: :harga_asc, halaman: 1, per_halaman: 20)

    result = @relation.aktif.where("stok > 0")

    result = result.where(kategori_id: kategori_id) if kategori_id
    result = result.where("harga >= ?", min_harga)  if min_harga
    result = result.where("harga <= ?", maks_harga) if maks_harga
    result = result.where("nama ILIKE ?", "%#{kata_kunci}%") if kata_kunci

    result = case urut.to_sym
             when :harga_asc  then result.order(:harga)
             when :harga_desc then result.order(harga: :desc)
             when :terbaru    then result.order(created_at: :desc)
             when :terlaris   then result.joins(:item_pesanan).group(:id).order("COUNT(*) DESC")
             else result.order(:nama)
             end

    result.page(halaman).per(per_halaman)
  end
end

# Penggunaan di controller
def index
  @produk = ProdukTersediaQuery.new.call(
    kategori_id: params[:kategori_id],
    kata_kunci:  params[:q],
    urut:        params[:urut] || :terbaru,
    halaman:     params[:page] || 1
  )
end

# Mudah ditest
RSpec.describe ProdukTersediaQuery do
  it "mengembalikan hanya produk aktif dengan stok" do
    create(:produk, aktif: true,  stok: 5)
    create(:produk, aktif: false, stok: 5)
    create(:produk, aktif: true,  stok: 0)

    hasil = ProdukTersediaQuery.new.call
    expect(hasil.length).to eq(1)
  end
end

Memilih ORM yang Tepat #

flowchart TD
    A[Pilih ORM] --> B{Framework?}
    B --> C["Rails"]
    B --> D["Sinatra / Hanami / Rack"]
    B --> E["Tanpa Framework"]
    C --> F["ActiveRecord\nDefault, ekosistem terlengkap"]
    D --> G{Prioritas?}
    G --> H["Produktivitas → Sequel + Sinatra"]
    G --> I["Arsitektur → ROM/Hanami::DB"]
    E --> J{Database?}
    J --> K["Relasional → Sequel\natau ActiveRecord standalone"]
    J --> L["MongoDB → Mongoid"]
    J --> M["Redis → Ohm\natau redis-objects"]
    J --> N["Multiple DB → ROM"]
Panduan singkat:
  ActiveRecord   → Rails, prototyping cepat, tim yang butuh konvensi
  Sequel         → Kontrol SQL penuh, non-Rails, query kompleks
  ROM            → DDD, clean architecture, multiple databases
  Mongoid        → MongoDB + Rails-like experience
  Ohm            → Model sederhana di Redis, sangat cepat
  DataMapper     → (legacy, tidak aktif dikembangkan)

Kapan Repository Pattern lebih baik dari ActiveRecord langsung:
  ✓ Domain model yang kompleks dan kaya logic
  ✓ Perlu test domain logic tanpa database (sangat cepat)
  ✓ Mungkin ganti database di masa depan
  ✓ Tim yang familiar dengan DDD dan clean architecture
  ✗ CRUD sederhana — ActiveRecord lebih dari cukup
  ✗ Tim kecil dengan deadline ketat

Ringkasan #

  • ActiveRecord untuk Rails dan produktivitas — konvensi yang ketat, ekosistem terlengkap, dan integrasi sempurna dengan Rails menjadikannya pilihan default yang sangat kuat untuk mayoritas aplikasi web.
  • Sequel untuk kontrol SQL penuh — lebih verbose dari ActiveRecord tapi setiap query yang dihasilkan bisa diprediksi; sangat baik untuk aplikasi dengan query yang sangat kompleks atau optimasi performa yang ketat.
  • ROM untuk arsitektur yang bersih — memisahkan domain logic dari persistence secara ketat; domain object bisa ditest tanpa database sama sekali, menghasilkan test suite yang jauh lebih cepat.
  • Mongoid untuk MongoDB + Rails experience — jika database adalah MongoDB, Mongoid memberikan familiar Rails-like API dengan embedded documents dan flexible schema.
  • Repository Pattern untuk testabilityInMemoryRepository memungkinkan test domain logic dengan kecepatan penuh tanpa menyentuh database; sangat berharga untuk proyek jangka panjang.
  • Query Object untuk query kompleks — jangan biarkan filter, sorting, dan pagination bercampur di model atau controller; enkapsulasi ke kelas Query Object yang bisa ditest secara terpisah.
  • N+1 adalah masalah di semua ORM — selalu analisis query yang dihasilkan; includes/eager_load (ActiveRecord) atau eager (Sequel/ROM) adalah solusi; gunakan Bullet gem untuk deteksi otomatis.
  • Plugin Sequel untuk fitur opsionalplugin :timestamps, plugin :soft_deletes, plugin :pagination harus di-opt-in secara eksplisit; berbeda dari ActiveRecord yang menyertakan semua secara default.
  • Hindari fat model di ActiveRecord — pindahkan business logic ke Service Object, Query Object, atau Form Object; model sebaiknya hanya punya validasi, scope, asosiasi, dan method yang benar-benar terkait dengan entity.
  • ORM bukan pengganti pemahaman SQL — apapun ORM yang kamu pilih, pahami SQL yang dihasilkan; to_sql (ActiveRecord), sql (Sequel), atau log query adalah alat yang harus selalu kamu periksa saat optimasi.

← Sebelumnya: Sinatra   Berikutnya: Selenium →

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