Relasi antartabel pada Ecto dinyatakan sekali di dalam schema, dan setelah itu relasi diperlakukan sebagai bagian dari struct: anggota membawa daftar peminjamannya, peminjaman membawa anggota dan bukunya, sedangkan buku membawa daftar pengarangnya. Topik ini membahas tiga deklarasi asosiasi, yaitu has_many, belongs_to, dan many_to_many, migrasi yang menyertainya, serta preload yang mengisi relasi dengan query tambahan. Contoh yang digunakan adalah skenario perpustakaan dengan anggota, buku, pengarang, dan peminjaman sekaligus.
Tiga bentuk asosiasi#
Deklarasi asosiasi terdiri atas tiga bentuk yang saling melengkapi, yaitu has_many untuk sisi yang memiliki banyak baris pada tabel lain, belongs_to untuk sisi yang menyimpan kunci asing, dan many_to_many untuk relasi yang dijembatani tabel ketiga. Keempat schema perpustakaan memuat semuanya:
# tidak-dijalankan
defmodule Anggota do
use Ecto.Schema
schema "anggota" do
field :nama, :string
has_many :peminjaman, Peminjaman
timestamps()
end
end
defmodule Peminjaman do
use Ecto.Schema
schema "peminjaman" do
field :status, :string
belongs_to :anggota, Anggota
belongs_to :buku, Buku
timestamps()
end
end
defmodule Buku do
use Ecto.Schema
schema "buku" do
field :judul, :string
many_to_many :pengarang, Pengarang, join_through: "penulisan"
timestamps()
end
endSetiap bentuk menuntut satu kesepakatan dengan database. belongs_to :anggota menambahkan field anggota_id pada schema peminjaman, dan migrasinya membuat kolom yang sama dengan add :anggota_id, references(:anggota) agar database ikut menjaga keabsahan nilainya. has_many :peminjaman tidak menambahkan kolom apa pun pada anggota, melainkan menandai field virtual yang menghubungkan satu anggota dengan seluruh peminjaman yang anggota_id-nya cocok. Adapun many_to_many menunjuk tabel penghubung penulisan melalui join_through, dan tabel itu dibuat tanpa kunci utama karena tugasnya hanya menghubungkan dua kunci asing:
# tidak-dijalankan
defmodule BuatPenulisan do
use Ecto.Migration
def up do
create table(:penulisan, primary_key: false) do
add :buku_id, references(:buku)
add :pengarang_id, references(:pengarang)
end
end
def down, do: drop table(:penulisan)
endDengan join_through berupa nama tabel, Ecto mengharapkan kolom buku_id dan pengarang_id pada tabel penghubung, yaitu nama yang dirangkai dari kedua schema yang dihubungkan. Satu buku boleh memiliki banyak pengarang dan satu pengarang menulis banyak buku, sehingga satu baris buku tetap memerlukan banyak baris penulisan, dan relasi ini tidak dapat dinyatakan sebagai has_many atau belongs_to biasa.
Preload dan biaya query-nya#
Field asosiasi pada hasil query tidak berisi data, melainkan penanda %Ecto.Association.NotLoaded{}, dan Repo.preload/2 mengisinya dengan query tambahan. Pemanggilan Repo.preload(anggota_list, :peminjaman) menjalankan satu query peminjaman dengan syarat anggota_id IN (...), lalu membagi hasilnya ke masing-masing anggota. Preload bersarang dinyatakan dengan map, misalnya peminjaman: :buku yang mengisi peminjaman setiap anggota sekaligus buku pada setiap peminjaman.
# tidak-dijalankan
Repo.all(from(a in Anggota, order_by: a.nama))
|> Repo.preload(peminjaman: :buku)
|> Enum.flat_map(& &1.peminjaman)
|> Enum.sort_by(& &1.id)
|> Enum.each(fn p -> IO.puts(" #{p.buku.judul}: #{p.status}") end)Biaya preload perlu dibaca dengan tepat agar tidak disalahpahami. Satu pemanggilan preload menambah satu query per asosiasi, bukan satu query per baris: seratus anggota dengan preload peminjaman tetap hanya menambah satu query peminjaman. Tanpa preload, kode yang mengakses a.peminjaman untuk setiap anggota akan menjalankan satu query per anggota, pola yang dikenal sebagai N+1 dan menumpuk menjadi ratusan query ke database untuk daftar yang panjang. Preload menggantikan pola itu dengan jumlah query yang tetap kecil, dan kebiasaan yang baik adalah memuat seluruh relasi yang dibutuhkan pada satu pemanggilan preload di awal.
Bentuk argumen preload mengikuti bentuk relasi yang diminta. Argumennya dapat berupa atom tunggal, list berisi beberapa asosiasi, atau map bersarang untuk relasi di dalam relasi, dan satu struct pun diterima selain list struct, sehingga relasi pada hasil Repo.get/2 dapat dimuat dengan cara yang sama. Nilai pada map juga boleh berupa query Ecto, sehingga peminjaman yang dimuat dapat disaring lebih dahulu, misalnya hanya yang berstatus "dipinjam", tanpa mengubah deklarasi asosiasinya. Fleksibilitas ini menjadikan preload bukan sekadar pengisi data, melainkan titik pengendalian berapa dan apa yang dimuat dari database.
Skrip utuh: laporan perpustakaan#
Skrip berikut merakit contoh perpustakaan yang utuh, yaitu lima tabel dengan dua migrasi bertanda kunci asing dan satu tabel penghubungnya, lalu mengisi data dan mencetak tiga laporan. Ketiga laporan menggunakan pendekatan preload yang berbeda: hitungan peminjaman per anggota, judul buku pada tiap peminjaman melalui preload bersarang, dan daftar pengarang tiap buku melalui many_to_many:
# perpustakaan.exs — has_many, belongs_to, many_to_many, dan preload
Mix.install([{:ecto_sql, "~> 3.13"}, {:postgrex, "~> 0.21"}])
Logger.configure(level: :warning)
Application.put_env(:jalur, Repo,
hostname: "127.0.0.1", port: 5433, username: "postgres",
database: "jalur_asosiasi", pool_size: 2, log: false, queue_target: 500)
defmodule Repo do
use Ecto.Repo, otp_app: :jalur, adapter: Ecto.Adapters.Postgres
end
# membuat database bila belum ada, lewat koneksi admin yang terpisah
{:ok, admin} =
Postgrex.start_link(
hostname: "127.0.0.1", port: 5433, username: "postgres", database: "postgres"
)
case Postgrex.query(admin, "CREATE DATABASE jalur_asosiasi", []) do
{:ok, _} -> :dibuat
{:error, %{postgres: %{code: :duplicate_database}}} -> :sudah_ada
end
{:ok, _} = Repo.start_link()
Repo.query!("DROP SCHEMA public CASCADE")
Repo.query!("CREATE SCHEMA public")
defmodule Anggota do
use Ecto.Schema
schema "anggota" do
field :nama, :string
has_many :peminjaman, Peminjaman
timestamps()
end
end
defmodule Buku do
use Ecto.Schema
schema "buku" do
field :judul, :string
many_to_many :pengarang, Pengarang, join_through: "penulisan"
timestamps()
end
end
defmodule Pengarang do
use Ecto.Schema
schema "pengarang" do
field :nama, :string
many_to_many :buku, Buku, join_through: "penulisan"
timestamps()
end
end
defmodule Peminjaman do
use Ecto.Schema
schema "peminjaman" do
field :status, :string
belongs_to :anggota, Anggota
belongs_to :buku, Buku
timestamps()
end
end
defmodule BuatAnggota do
use Ecto.Migration
def up do
create table(:anggota) do
add :nama, :string
timestamps()
end
end
def down, do: drop table(:anggota)
end
defmodule BuatBuku do
use Ecto.Migration
def up do
create table(:buku) do
add :judul, :string
timestamps()
end
end
def down, do: drop table(:buku)
end
defmodule BuatPengarang do
use Ecto.Migration
def up do
create table(:pengarang) do
add :nama, :string
timestamps()
end
end
def down, do: drop table(:pengarang)
end
defmodule BuatPeminjaman do
use Ecto.Migration
def up do
create table(:peminjaman) do
add :status, :string
add :anggota_id, references(:anggota)
add :buku_id, references(:buku)
timestamps()
end
end
def down, do: drop table(:peminjaman)
end
defmodule BuatPenulisan do
use Ecto.Migration
def up do
create table(:penulisan, primary_key: false) do
add :buku_id, references(:buku)
add :pengarang_id, references(:pengarang)
end
end
def down, do: drop table(:penulisan)
end
Ecto.Migrator.run(Repo, [
{20261106000001, BuatAnggota},
{20261106000002, BuatBuku},
{20261106000003, BuatPengarang},
{20261106000004, BuatPeminjaman},
{20261106000005, BuatPenulisan}
], :up, all: true)
{:ok, budi} = Repo.insert(struct(Anggota, nama: "Budi Santoso"))
{:ok, rina} = Repo.insert(struct(Anggota, nama: "Rina Melati"))
Repo.insert!(struct(Anggota, nama: "Fajar Nugroho"))
{:ok, logika} = Repo.insert(struct(Buku, judul: "Pengantar Logika"))
{:ok, maritim} = Repo.insert(struct(Buku, judul: "Sejarah Maritim Nusantara"))
{:ok, citra} = Repo.insert(struct(Pengarang, nama: "Citra Kusuma"))
{:ok, ali} = Repo.insert(struct(Pengarang, nama: "Ali Rahman"))
Repo.insert!(struct(Peminjaman, status: "dipinjam", anggota_id: budi.id, buku_id: logika.id))
Repo.insert!(struct(Peminjaman, status: "kembali", anggota_id: budi.id, buku_id: maritim.id))
Repo.insert!(struct(Peminjaman, status: "dipinjam", anggota_id: rina.id, buku_id: maritim.id))
Repo.insert_all("penulisan", [
%{buku_id: logika.id, pengarang_id: citra.id},
%{buku_id: logika.id, pengarang_id: ali.id},
%{buku_id: maritim.id, pengarang_id: ali.id}
])
defmodule Laporan do
import Ecto.Query
def anggota_urut do
from(a in Anggota, order_by: a.nama)
end
def buku_urut do
from(b in Buku, order_by: b.judul)
end
end
IO.puts("peminjaman per anggota:")
Repo.all(Laporan.anggota_urut())
|> Repo.preload(:peminjaman)
|> Enum.each(fn a ->
aktif = Enum.count(a.peminjaman, &(&1.status == "dipinjam"))
IO.puts(" #{a.nama}: #{Enum.count(a.peminjaman)} peminjaman, #{aktif} aktif")
end)
IO.puts("judul buku pada tiap peminjaman (preload bersarang):")
Repo.all(Laporan.anggota_urut())
|> Repo.preload(peminjaman: :buku)
|> Enum.flat_map(& &1.peminjaman)
|> Enum.sort_by(& &1.id)
|> Enum.each(fn p -> IO.puts(" #{p.buku.judul}: #{p.status}") end)
IO.puts("pengarang per buku (many_to_many):")
Repo.all(Laporan.buku_urut())
|> Repo.preload(:pengarang)
|> Enum.each(fn b ->
nama = b.pengarang |> Enum.map(& &1.nama) |> Enum.sort() |> Enum.join(", ")
IO.puts(" #{b.judul} ditulis oleh #{nama}")
end)peminjaman per anggota:
Budi Santoso: 2 peminjaman, 1 aktif
Fajar Nugroho: 0 peminjaman, 0 aktif
Rina Melati: 1 peminjaman, 1 aktif
judul buku pada tiap peminjaman (preload bersarang):
Pengantar Logika: dipinjam
Sejarah Maritim Nusantara: kembali
Sejarah Maritim Nusantara: dipinjam
pengarang per buku (many_to_many):
Pengantar Logika ditulis oleh Ali Rahman, Citra Kusuma
Sejarah Maritim Nusantara ditulis oleh Ali RahmanSkrip ini dijalankan dengan perintah berikut dari folder topiknya:
# menjalankan skrip perpustakaan
elixir perpustakaan.exspeminjaman per anggota:
Budi Santoso: 2 peminjaman, 1 aktif
Fajar Nugroho: 0 peminjaman, 0 aktif
Rina Melati: 1 peminjaman, 1 aktif
judul buku pada tiap peminjaman (preload bersarang):
Pengantar Logika: dipinjam
Sejarah Maritim Nusantara: kembali
Sejarah Maritim Nusantara: dipinjam
pengarang per buku (many_to_many):
Pengantar Logika ditulis oleh Ali Rahman, Citra Kusuma
Sejarah Maritim Nusantara ditulis oleh Ali RahmanLaporan pertama memperlihatkan bahwa has_many bekerja tanpa satu pun query tambahan per baris: Repo.preload(:peminjaman) mengisi seluruh anggota sekaligus, dan anggota tanpa peminjaman seperti Fajar Nugroho menerima list kosong alih-alih error. Laporan kedua menggunakan preload bersarang peminjaman: :buku, sehingga judul buku terbaca langsung dari p.buku.judul, dan pengurutan menurut id membuat urutan keluarannya tetap. Laporan ketiga mengisi pengarang melalui tabel penulisan, dan nama pengarangnya diurutkan di sisi Elixir karena urutan kembalian preload tidak dijamin. Baris penghubung sendiri disisipkan dengan Repo.insert_all("penulisan", ...) yang menggunakan nama tabel langsung, karena tabel penghubung tidak memiliki schema tersendiri.
Kesalahan umum#
Mengakses asosiasi tanpa preload adalah kesalahan yang paling sering lolos hingga runtime. Nilai a.peminjaman yang belum dimuat bukan list kosong, melainkan struct %Ecto.Association.NotLoaded{}, sehingga operasi list seperti Enum.count/1 gagal dengan Protocol.UndefinedError di tengah kode yang terlihat benar. Perbaikannya selalu sama, yaitu memanggil Repo.preload/2 sebelum relasi diakses, dan kegagalannya mudah dikenali karena nama tipe penandanya ikut tampil pada pesan error.
Kesalahan kedua menyangkut kesepakatan join_through. Apabila nama tabel penghubung atau nama kolom kunci asingnya tidak cocok dengan yang diharapkan Ecto, preload many_to_many berhenti dengan error tentang tabel atau kolom yang tidak ditemukan, dan kesalahan itu baru terlihat saat preload dijalankan, bukan saat schema didefinisikan. Memastikan join_through menunjuk tabel yang benar dengan kolom buku_id dan pengarang_id adalah bagian dari perubahan schema itu sendiri, sebagaimana migrasinya.
Ringkasan#
belongs_tomenambahkan field kunci asing pada schema pemiliknya,has_manymenandai relasi satu-ke-banyak dari sisi lawannya, danmany_to_manymenjembatani keduanya melaluijoin_through.- Field asosiasi yang belum dimuat berupa
%Ecto.Association.NotLoaded{}, sedangkanRepo.preload/2mengisinya dengan satu query per asosiasi dan mendukung preload bersarang. - Preload mencegah pola N+1 karena jumlah query bertambah per asosiasi, bukan per baris.
- Tabel penghubung tanpa schema diisi melalui
Repo.insert_all/3dengan nama tabel langsung.
Referensi#
- Schema associations — Hexdocs Ecto — panduan resmi has_many, belongs_to, dan many_to_many.
- Ecto.Association — Hexdocs Ecto — modul yang menangani relasi beserta tipe-tipenya.
- Ecto.Repo:
preload/3— Hexdocs Ecto — bentuk preload tunggal, bersarang, dan per query. - Ecto.Schema — Hexdocs Ecto — dokumentasi deklarasi asosiasi di dalam schema.