Schema dan migration

Model data di atas SQL beserta migrasinya.

Daftar isi

Schema dan migration adalah dua sisi dari satu pekerjaan yang sama, yaitu menetapkan bentuk data: schema menetapkan bentuk pada sisi Elixir sebagai struct, sedangkan migration menetapkan bentuk pada sisi database sebagai tabel. Topik ini membahas keduanya pada tabel buku perpustakaan, mulai dari definisi schema dengan use Ecto.Schema, migrasi dengan use Ecto.Migration dan Ecto.Migrator.run/4, sampai penyimpanan dan pembacaan baris pertama. Tabel buku ini kemudian digunakan kembali pada pembahasan changeset, query, dan asosiasi.

Schema sebagai peta tabel#

Schema adalah modul Elixir yang memetakan satu tabel database ke satu struct, sehingga setiap baris tabel menjadi nilai struct yang dapat dipola dan dialirkan seperti data Elixir lain. Definisinya diawali use Ecto.Schema, diikuti blok schema/1 yang menyebut nama tabelnya, lalu daftar field yang menyatakan kolom beserta tipenya:

elixir Schema untuk tabel buku
# tidak-dijalankan
defmodule Buku do
  use Ecto.Schema

  schema "buku" do
    field :judul, :string
    field :pengarang, :string
    field :tahun, :integer
    timestamps()
  end
end

Tiga mekanisme pada definisi tersebut bekerja secara berbeda. schema "buku" menghubungkan struct dengan tabel bernama buku, dan nama modulnya bebas sehingga modul Buku boleh memetakan tabel buku tanpa aturan penamaan yang ketat. Setiap field menambahkan kunci baru pada struct beserta tipenya, dan Ecto menggunakan tipe ini untuk mengubah nilai Elixir menjadi nilai database dan sebaliknya. Adapun timestamps() menambahkan dua kolom sekaligus, yaitu inserted_at dan updated_at, yang diisi Ecto secara otomatis setiap kali baris disimpan atau diubah.

Schema juga tetap dapat digunakan tanpa database sama sekali. struct(Buku, judul: "Pengantar Logika") membangun struct buku di memori tanpa menyentuh koneksi, sehingga fungsi yang menerima %Buku{} dapat diuji terpisah dari lapisan persistensinya. Pemisahan ini membuat schema bukan sekadar daftar kolom, melainkan kontrak bentuk data yang dipegang seluruh aplikasi.

Migration dan catatan versinya#

Migration adalah modul yang mengubah bentuk database secara bertahap, yaitu menambah tabel, kolom, atau indeks, dan setiap langkahnya dicatat sebagai satu versi bernomor. Definisinya diawali use Ecto.Migration, dan fungsi up/0 memuat perubahan yang ingin diterapkan sedangkan down/0 memuat kebalikannya:

elixir Migration untuk tabel buku
# tidak-dijalankan
defmodule BuatBuku do
  use Ecto.Migration

  def up do
    create table(:buku) do
      add :judul, :string
      add :pengarang, :string
      add :tahun, :integer
      timestamps()
    end
  end

  def down, do: drop table(:buku)
end

create table(:buku) menambahkan kolom id bertipe bigserial secara otomatis sebagai kunci utama, dan add menyatakan kolom lainnya dengan tipe yang sama seperti pada schema. Dua modul di atas baru berupa definisi, sehingga penerapannya dilakukan oleh Ecto.Migrator.run/4 yang menerima daftar pasangan versi dan modul migrasinya:

elixir Menjalankan migrasi dengan nomor versi
# tidak-dijalankan
Ecto.Migrator.run(Repo, [{20261106000001, BuatBuku}], :up, all: true)

Nomor versi 20261106000001 berperan dalam pencatatan migrasi. Setelah migrasi lulus, Ecto mencatat nomor itu ke tabel schema_migrations, sehingga migrasi yang sudah diterapkan tidak dijalankan ulang pada eksekusi berikutnya. Perintah mix ecto.migrate pada proyek sungguhan menjalankan mekanisme yang sama dengan membaca berkas migrasi dari folder priv/repo/migrations, dan nomor versinya lazim ditulis sebagai tanggal pengerjaan migrasi.

Skrip utuh: katalog buku#

Skrip berikut menggabungkan schema, migrasi, dan data pertama menjadi satu alur yang dapat dijalankan berulang. Database direset terlebih dahulu agar selalu dimulai dari kondisi yang sama, migrasi diterapkan, tiga buku disimpan, lalu isi katalog dibaca kembali:

elixir katalog.exs
# katalog.exs — schema Buku, migrasi, dan katalog perpustakaan
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_schema", 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_schema", []) 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 Buku do
  use Ecto.Schema
  schema "buku" do
    field :judul, :string
    field :pengarang, :string
    field :tahun, :integer
    timestamps()
  end
end

defmodule BuatBuku do
  use Ecto.Migration

  def up do
    create table(:buku) do
      add :judul, :string
      add :pengarang, :string
      add :tahun, :integer
      timestamps()
    end
  end

  def down, do: drop table(:buku)
end

Ecto.Migrator.run(Repo, [{20261106000001, BuatBuku}], :up, all: true)

Repo.insert!(struct(Buku, judul: "Pengantar Logika", pengarang: "Citra Kusuma", tahun: 2018))
Repo.insert!(struct(Buku, judul: "Sejarah Maritim Nusantara", pengarang: "Ali Rahman", tahun: 2020))
Repo.insert!(struct(Buku, judul: "Panduan Menanam Cabai", pengarang: "Dewi Anggraini", tahun: 2019))

IO.puts("katalog buku:")
Repo.all(Buku)
|> Enum.sort_by(& &1.judul)
|> Enum.each(fn b -> IO.puts("  #{b.judul} (#{b.tahun}) oleh #{b.pengarang}") end)

[[versi]] = Repo.query!("SELECT version FROM schema_migrations").rows
IO.puts("versi migrasi tercatat: #{versi}")
IO.puts("jumlah baris: #{Repo.aggregate(Buku, :count, :id)}")
text Keluaran
katalog buku:
  Panduan Menanam Cabai (2019) oleh Dewi Anggraini
  Pengantar Logika (2018) oleh Citra Kusuma
  Sejarah Maritim Nusantara (2020) oleh Ali Rahman
versi migrasi tercatat: 20261106000001
jumlah baris: 3

Skrip ini dijalankan dengan perintah berikut dari folder topiknya:

bash
# menjalankan skrip katalog
elixir katalog.exs
text Keluaran
katalog buku:
  Panduan Menanam Cabai (2019) oleh Dewi Anggraini
  Pengantar Logika (2018) oleh Citra Kusuma
  Sejarah Maritim Nusantara (2020) oleh Ali Rahman
versi migrasi tercatat: 20261106000001
jumlah baris: 3

Tiga baris pertama keluaran berasal dari Repo.all/1 yang mengembalikan list struct Buku, dan urutannya dijaga oleh Enum.sort_by/2 karena urutan kembalian database tanpa order_by tidak dijamin. Baris keempat menunjukkan bahwa Ecto.Migrator.run/4 telah menulis versi migrasinya ke schema_migrations, sehingga pemanggilan ulang skrip tidak menjalankan up/0 dua kali pada migrasi yang sama. Repo.insert!/1 yang menyimpan tiap buku mengembalikan struct lengkap dengan id dan kedua timestamp, dan kedua nilai waktu itu sengaja tidak dicetak karena bukan bagian dari data yang dipelajari.

Kesalahan umum#

Ketidaksesuaian antara schema dan migration adalah sumber error yang paling membingungkan karena gejalanya muncul jauh dari penyebabnya. Field yang ada di schema tetapi tidak ada di tabel memunculkan error undefined column saat query dijalankan. Sebaliknya, kolom yang ada di tabel tetapi lupa dinyatakan di schema tidak menimbulkan error sama sekali, hanya saja datanya tidak pernah terlihat dari Elixir. Kebiasaan yang mencegah keduanya adalah menuliskan schema dan migration berdampingan pada satu perubahan, sehingga setiap add pada migrasi memiliki pasangan field pada schema.

Kesalahan kedua adalah menjalankan isi migrasi secara manual di luar Ecto.Migrator.run/4. Pemanggilan langsung fungsi migrasi tidak mencatat versi apa pun ke schema_migrations, sehingga eksekusi berikutnya menjalankan up/0 lagi dan berakhir dengan error tabel yang sudah ada. Catatan versi adalah seluruh nilai dari mekanisme migrasi, sehingga migrasi selalu dieksekusi melalui migratornya, baik melalui Ecto.Migrator.run/4 pada skrip maupun melalui mix ecto.migrate pada proyek.

Ringkasan#

  • Schema memetakan satu tabel ke satu struct melalui use Ecto.Schema, blok schema/1, dan field, dengan timestamps() menambahkan inserted_at dan updated_at yang diisi otomatis.
  • Migration mengubah bentuk database dalam fungsi up/0 dan down/0, dan penerapannya melalui Ecto.Migrator.run/4 dicatat sebagai nomor versi pada tabel schema_migrations.
  • Repo.all/1 mengembalikan list struct dari hasil query, sedangkan Repo.insert!/1 menyimpan satu struct dan mengembalikan versi lengkapnya.
  • Ketidaksesuaian schema dan migration sebaiknya dicegah dengan menulis keduanya berdampingan pada setiap perubahan bentuk data.

Referensi#