Data yang datang dari luar tidak boleh langsung disimpan: isinya belum tentu lengkap, tipenya belum tentu benar, dan belum tentu memenuhi aturan domain. Changeset adalah struktur data Ecto yang menampung alur pemeriksaan itu, yaitu data awal, data baru dari luar, dan daftar error yang terkumpul sepanjang proses. Topik ini membahas cast/3 untuk memasukkan data dari luar, fungsi validasi bawaan, dan pembacaan pesan error secara deterministik. Pembahasan dilanjutkan dengan pemicuan constraint, yaitu aturan yang dijaga database sendiri, serta apply_changes yang menerapkan data tanpa menyentuh database. Seluruh contoh berjalan pada tabel peminjaman perpustakaan.
Cast: gerbang data dari luar#
cast/3 adalah gerbang masuk data eksternal ke dalam changeset: fungsi ini menyalin kunci yang diizinkan dari map atribut ke dalam changeset, lengkap dengan konversi tipe menurut schema. Daftar kunci yang diizinkan bersifat whitelist, sehingga kunci lain pada atribut diabaikan tanpa error, dan nilai yang gagal dikonversi ditampung sebagai error tipe alih-alih menghentikan program. Struktur changeset yang dihasilkan memuat tiga bagian, yaitu changeset.data berupa data awal, changeset.changes berupa perubahan yang lolos cast, dan changeset.errors berupa daftar error.
Pada schema Peminjaman, fungsi changeset/2 merangkai cast dan seluruh validasi menjadi satu fungsi yang dipanggil setiap kali data peminjaman masuk:
# tidak-dijalankan
defmodule Peminjaman do
use Ecto.Schema
import Ecto.Changeset
schema "peminjaman" do
field :anggota, :string
field :judul_buku, :string
field :lama_hari, :integer
field :status, :string, default: "dipinjam"
timestamps()
end
def changeset(peminjaman, attrs) do
peminjaman
|> cast(attrs, [:anggota, :judul_buku, :lama_hari, :status])
|> validate_required([:anggota, :judul_buku, :lama_hari])
|> validate_length(:anggota, min: 3)
|> validate_inclusion(:lama_hari, 1..30)
|> validate_inclusion(:status, ["dipinjam", "kembali"])
|> unique_constraint([:anggota, :judul_buku])
end
endimport Ecto.Changeset dibutuhkan agar fungsi seperti cast/3 dapat dipanggil tanpa menyebut modulnya. Bentuk pipeline pada fungsi di atas adalah gaya baku Ecto: setiap fungsi menerima changeset dan mengembalikan changeset baru, sehingga urutan pemeriksaannya terbaca dari urutan pipanya. Field status diberi nilai default "dipinjam" pada schema, dan default itu tetap berlaku apabila atribut yang masuk tidak membawa kunci tersebut.
Validasi dan pesan error#
Fungsi validasi tidak menghentikan alur ketika menemukan data yang tidak lolos: fungsi itu menambahkan error ke changeset, lalu mengembalikan changeset yang diperbarui, sehingga seluruh pelanggaran terkumpul dalam satu kali pemeriksaan. validate_required/2 menandai field yang wajib terisi, validate_length/3 memeriksa panjang nilai string, dan validate_inclusion/3 memeriksa keanggotaan nilai pada rentang atau list. Validasi keseluruhan dirangkum oleh changeset.valid? yang bernilai false begitu ada satu saja error.
Pesan error bawaan Ecto berupa templat yang masih memuat placeholder seperti %{count} atau %{enum}, sehingga perlu diinterpolasi sebelum ditampilkan. Fungsi Ecto.Changeset.traverse_errors/2 menelusuri changeset, termasuk changeset bersarang pada asosiasi, dan menerapkan fungsi pengubah pada setiap pasangan pesan dan opsinya:
# tidak-dijalankan
defmodule PesanError do
def interpolasi({msg, opts}) do
Regex.replace(~r"%{(\w+)}", msg, fn _, kunci ->
opts |> Keyword.get(String.to_existing_atom(kunci), kunci) |> to_string()
end)
end
def daftar(changeset) do
changeset
|> Ecto.Changeset.traverse_errors(&interpolasi/1)
|> Enum.sort()
|> Enum.map(fn {field, daftar} -> "#{field}: #{Enum.join(daftar, ", ")}" end)
end
endPengurutan dengan Enum.sort/1 pada fungsi di atas bukan sekadar pilihan gaya: map pada Elixir tidak menjamin urutan kunci saat ditampilkan, sehingga daftar error per field baru dapat dibandingkan antareksekusi apabila diurutkan lebih dahulu. Pola ini digunakan apa adanya pada skrip di bawah.
Skrip utuh: dua changeset dan satu constraint#
Skrip berikut menempuh seluruh siklus pada data peminjaman. Migrasi membuat tabel beserta indeks unik parsial yang hanya berlaku untuk peminjaman berstatus "dipinjam", sehingga satu anggota tidak dapat meminjam buku yang sama dua kali dalam waktu bersamaan. Tiga changeset kemudian dibuat: yang pertama lulus dan tersimpan, yang kedua lulus validasi tetapi gagal tersimpan karena constraint, dan yang ketiga gagal validasi dengan empat error:
# peminjaman.exs — changeset valid, tidak valid, dan constraint
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_changeset", 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_changeset", []) 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 Peminjaman do
use Ecto.Schema
import Ecto.Changeset
schema "peminjaman" do
field :anggota, :string
field :judul_buku, :string
field :lama_hari, :integer
field :status, :string, default: "dipinjam"
timestamps()
end
def changeset(peminjaman, attrs) do
peminjaman
|> cast(attrs, [:anggota, :judul_buku, :lama_hari, :status])
|> validate_required([:anggota, :judul_buku, :lama_hari])
|> validate_length(:anggota, min: 3)
|> validate_inclusion(:lama_hari, 1..30)
|> validate_inclusion(:status, ["dipinjam", "kembali"])
|> unique_constraint([:anggota, :judul_buku])
end
end
defmodule BuatPeminjaman do
use Ecto.Migration
def up do
create table(:peminjaman) do
add :anggota, :string
add :judul_buku, :string
add :lama_hari, :integer
add :status, :string
timestamps()
end
create unique_index(:peminjaman, [:anggota, :judul_buku], where: "status = 'dipinjam'")
end
def down, do: drop table(:peminjaman)
end
Ecto.Migrator.run(Repo, [{20261106000001, BuatPeminjaman}], :up, all: true)
defmodule PesanError do
def interpolasi({msg, opts}) do
Regex.replace(~r"%{(\w+)}", msg, fn _, kunci ->
opts |> Keyword.get(String.to_existing_atom(kunci), kunci) |> to_string()
end)
end
def daftar(changeset) do
changeset
|> Ecto.Changeset.traverse_errors(&interpolasi/1)
|> Enum.sort()
|> Enum.map(fn {field, daftar} -> "#{field}: #{Enum.join(daftar, ", ")}" end)
end
end
# changeset pertama: seluruh validasi lulus
attrs_ok = %{anggota: "Ali Rahman", judul_buku: "Pengantar Logika", lama_hari: 14}
cs1 = Peminjaman.changeset(struct(Peminjaman), attrs_ok)
IO.puts("changeset 1 valid: #{cs1.valid?}")
{:ok, p1} = Repo.insert(cs1)
IO.puts("changeset 1 tersimpan dengan id: #{p1.id}")
# changeset kedua: peminjaman yang sama dicatat dua kali
cs2 = Peminjaman.changeset(struct(Peminjaman), attrs_ok)
IO.puts("changeset 2 valid: #{cs2.valid?}")
case Repo.insert(cs2) do
{:ok, _} -> IO.puts("changeset 2 tersimpan")
{:error, cs2err} ->
IO.puts("changeset 2 gagal tersimpan:")
PesanError.daftar(cs2err) |> Enum.each(&IO.puts(" " <> &1))
end
# changeset ketiga: data yang rusak
attrs_rusak = %{anggota: "B", judul_buku: nil, lama_hari: 45, status: "hilang"}
cs3 = Peminjaman.changeset(struct(Peminjaman), attrs_rusak)
IO.puts("changeset 3 valid: #{cs3.valid?}")
PesanError.daftar(cs3) |> Enum.each(&IO.puts(" " <> &1))
# apply_changes: menerapkan data tanpa menyentuh database
cs4 = Peminjaman.changeset(struct(Peminjaman), %{
anggota: "Citra Kusuma", judul_buku: "Sejarah Maritim Nusantara", lama_hari: 7
})
data = Ecto.Changeset.apply_changes(cs4)
IO.puts("apply_changes: #{data.anggota} meminjam #{data.judul_buku} selama #{data.lama_hari} hari (status #{data.status})")changeset 1 valid: true
changeset 1 tersimpan dengan id: 1
changeset 2 valid: true
changeset 2 gagal tersimpan:
anggota: has already been taken
changeset 3 valid: false
anggota: should be at least 3 character(s)
judul_buku: can't be blank
lama_hari: is invalid
status: is invalid
apply_changes: Citra Kusuma meminjam Sejarah Maritim Nusantara selama 7 hari (status dipinjam)Skrip ini dijalankan dengan perintah berikut dari folder topiknya:
# menjalankan skrip peminjaman
elixir peminjaman.exschangeset 1 valid: true
changeset 1 tersimpan dengan id: 1
changeset 2 valid: true
changeset 2 gagal tersimpan:
anggota: has already been taken
changeset 3 valid: false
anggota: should be at least 3 character(s)
judul_buku: can't be blank
lama_hari: is invalid
status: is invalid
apply_changes: Citra Kusuma meminjam Sejarah Maritim Nusantara selama 7 hari (status dipinjam)Perilaku changeset kedua memperlihatkan cara kerja constraint pada keluaran di atas. Validasinya lulus karena data sama persis dengan changeset pertama, tetapi Repo.insert/1 mengembalikan {:error, changeset} karena database menolak baris kedua melalui indeks unik parsial. Ecto menerjemahkan penolakan itu menjadi error pada field pertama yang terdaftar di unique_constraint/2, yaitu anggota, sehingga pesan has already been taken muncul dalam bentuk yang sama dengan error validasi. Adapun changeset ketiga menunjukkan pesan bawaan tiap validasi. Pesan is invalid pada lama_hari dan status berasal dari validate_inclusion/3 yang menolak nilai di luar rentang atau listnya.
apply_changes/1 menutup skrip dengan mekanisme yang berbeda dari insert: data hasil cast diterapkan ke struct tanpa menyentuh database sama sekali. Hasilnya adalah struct Peminjaman yang siap digunakan untuk pekerjaan yang sepenuhnya berlangsung di memori, misalnya menghitung biaya denda atau menyusun surel pemberitahuan. Pemanggilan itu tidak meninggalkan baris apa pun pada tabel.
Kesalahan umum#
Field yang lupa didaftarkan pada cast/3 tidak pernah masuk changeset, dan Ecto tidak menimbulkan error apa pun atasnya. Gejalanya berupa kolom yang selalu kosong padahal atribut dari luar terlihat membawa nilainya. Perbaikannya sederhana, yaitu menyebutkan field tersebut pada daftar kunci yang diizinkan, dan pemeriksaan cepatnya adalah melihat changeset.changes sebelum insert untuk memastikan semua kunci yang diharapkan benar-benar masuk.
Constraint yang dinyatakan tanpa indeks pendampingnya di database tidak pernah terpicu. unique_constraint/2 bukan pemeriksaan Elixir, melainkan pendaftaran penerjemah: barulah ketika database menolak INSERT karena pelanggaran indeks unik, Ecto mengubah penolakan itu menjadi error pada changeset. Apabila migrasi lupa membuat unique_index/3 dengan nama yang cocok, insert ganda selalu berhasil secara diam-diam. Karena itu, pendaftaran constraint dan pembuatan indeksnya harus dilakukan dalam satu perubahan yang sama.
Ringkasan#
cast/3menyalin kunci yang diizinkan dari atribut eksternal dengan konversi tipe menurut schema, sedangkan fungsivalidate_*menambahkan error tanpa menghentikan alur.changeset.valid?merangkum validasi lokal saja, sedangkan constraint dievaluasi database saat INSERT dan diterjemahkan Ecto menjadi error field apabila didaftarkan melaluiunique_constraint/2.Ecto.Changeset.traverse_errors/2menyusun pesan error akhir, dan templat seperti%{count}diinterpolasi sebelum ditampilkan.apply_changes/1menerapkan data hasil cast ke struct tanpa menyentuh database.
Referensi#
- Ecto.Changeset — Hexdocs Ecto — dokumentasi resmi cast, fungsi validasi, dan constraint.
- traverse_errors/2 — Hexdocs Ecto — penelusuran seluruh error termasuk changeset bersarang.
- validate_required/3 — Hexdocs Ecto — aturan field wajib dan pesannya.
- apply_changes/1 — Hexdocs Ecto — penerapan data changeset ke struct tanpa database.