Asosiasi

has_many, belongs_to, many_to_many, dan preload.

Daftar isi

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:

elixir Deklarasi asosiasi pada schema perpustakaan
# 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
end

Setiap 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:

elixir Migration tabel penghubung penulisan
# 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)
end

Dengan 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.

elixir Preload bersarang dari sisi anggota
# 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:

elixir perpustakaan.exs
# 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)
text Keluaran
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 Rahman

Skrip ini dijalankan dengan perintah berikut dari folder topiknya:

bash
# menjalankan skrip perpustakaan
elixir perpustakaan.exs
text Keluaran
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 Rahman

Laporan 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_to menambahkan field kunci asing pada schema pemiliknya, has_many menandai relasi satu-ke-banyak dari sisi lawannya, dan many_to_many menjembatani keduanya melalui join_through.
  • Field asosiasi yang belum dimuat berupa %Ecto.Association.NotLoaded{}, sedangkan Repo.preload/2 mengisinya 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/3 dengan nama tabel langsung.

Referensi#