Query

Pipeline where/join/select yang dapat disusun.

Daftar isi

Query pada Ecto bukan teks SQL melainkan data: sebuah query dibangun sebagai nilai %Ecto.Query{} yang dapat disimpan dalam variabel, dilewatkan melalui pipeline, dan diperkaya bertahap sebelum dikirim ke database. Topik ini membahas pembentukan query dengan from/2, penyusunan bertahap dengan where, select, dan order_by, pengikatan nilai dari luar melalui operator pin, serta join antartabel pada katalog perpustakaan. Penulisan SQL mentah sendiri tidak menjadi fokus jalur Elixir karena keterampilan SQL dikuasai melalui jalur PostgreSQL.

Query sebagai data yang disusun#

from/2 membentuk query sebagai struct, sehingga query adalah nilai biasa yang dapat disimpan, dibandingkan, dan disusun tanpa merusak nilai asalnya. Ekspresi from(b in Buku, ...) menetapkan b sebagai pengikat (binding) yang mewakili tabel buku, dan setiap fungsi penyusun menerima query lama lalu mengembalikan query baru dengan kriteria tambahan. Query asal tetap utuh karena seluruh data Elixir bersifat imutabel, sehingga satu query dasar boleh menjadi induk bagi beberapa query turunan sekaligus.

Modul Pencarian pada skrip di bawah memuat query dasar beserta dua turunannya. Fungsi dasar_judul/0 menyusun dasar/0 dengan select dan order_by, sedangkan dasar_tersaring/0 menyusun dasar_judul/0 sekali lagi dengan where:

elixir Komposisi query bertahap
# tidak-dijalankan
defmodule Pencarian do
  import Ecto.Query

  def dasar do
    from(b in Buku, where: b.tahun >= 2019)
  end

  def dasar_judul do
    dasar()
    |> select([b], b.judul)
    |> order_by([b], b.judul)
  end

  def dasar_tersaring do
    dasar_judul()
    |> where([b], b.tahun <= 2020)
  end
end

import Ecto.Query pada modul tersebut membuat from, where, select, dan order_by dapat dipanggil tanpa awalan modul, sebagaimana kebiasaan modul context pada proyek Phoenix. Komposisi di atas memisahkan tanggung jawab: query dasar hanya menyatakan syarat tahun, sedangkan turunannya menambahkan bentuk keluaran dan pengurutan masing-masing. Perubahan syarat dasar otomatis berlaku pada seluruh turunannya, dan perilaku ini membedakan komposisi query dari penyalinan teks SQL.

Semantik tiap fungsi penyusun juga perlu dipahami agar komposisi terbaca dengan benar. Pemanggilan where lebih dari satu kali menggabungkan seluruh syarat dengan AND, sedangkan select yang dipanggil belakangan menggantikan bentuk keluaran sebelumnya tanpa menyimpan bentuk lamanya. Dengan demikian, urutan penyusunan tidak memengaruhi syarat penyaringan, tetapi menentukan bentuk akhir keluaran melalui satu select terakhir. Kebiasaan membaca query panjang pun menjadi sederhana: cari seluruh syarat where pada urutan komposisinya, lalu perhatikan satu select paling akhir untuk mengetahui bentuk data yang kembali.

Menyaring, memilih, dan mengurutkan#

where/3 menambahkan syarat baris, select/3 menentukan bentuk hasil, dan order_by/3 menetapkan urutannya, sedangkan ketiganya menerima pengikat yang sama seperti pada from/2. Tanpa select, query mengembalikan struct lengkap dari schema, sedangkan select: b.judul mengembalikan nilai mentah dan select: {b.judul, b.tahun} mengembalikan tuple berurutan. order_by: [asc: b.tahun, asc: b.judul] mengurutkan menurut beberapa kolom sekaligus, dan pengurutan eksplisit semacam ini wajib pada data yang akan dibandingkan antareksekusi karena urutan kembalian database tanpa order_by tidak dijamin.

Nilai dari luar query tidak pernah dirangkai sebagai teks. Operator pin ^ mengikat nilai variabel Elixir ke dalam query, sehingga nilai tersebut dikirim sebagai parameter terpisah dan tidak pernah menjadi bagian dari teks SQL:

elixir Nilai dari luar melalui operator pin
# tidak-dijalankan
pengarang = "Ali Rahman"

from(b in Buku, where: b.pengarang == ^pengarang, select: b.judul)

Pengikatan ini melindungi dua hal sekaligus. Pertama, nilai yang mengandung tanda kutip atau karakter apa pun tidak dapat mengubah maksud query karena database menerimanya sebagai data, bukan sebagai perintah. Kedua, query dengan pengikat berbeda tetap berupa satu bentuk pernyataan yang sama di sisi server, sehingga pemanggilan berulang dengan nilai berbeda tidak memaksa penyusunan rencana eksekusi baru.

Skrip utuh: pencarian katalog#

Skrip berikut membangun katalog lima buku beserta tiga catatan peminjamannya, lalu menjawab lima pertanyaan berbeda dengan query yang disusun dari atas ke bawah. Migrasi membuat tabel buku dan peminjaman tanpa deklarasi asosiasi, karena penggabungan pada topik ini dilakukan dengan join eksplisit:

elixir pencarian.exs
# pencarian.exs — query yang dapat disusun di atas data 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_query", 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_query", []) 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
  end
end

defmodule Peminjaman do
  use Ecto.Schema
  schema "peminjaman" do
    field :anggota, :string
    field :status, :string
    field :buku_id, :integer
  end
end

defmodule BuatBuku do
  use Ecto.Migration

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

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

defmodule BuatPeminjaman do
  use Ecto.Migration

  def up do
    create table(:peminjaman) do
      add :anggota, :string
      add :status, :string
      add :buku_id, :integer
    end
  end

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

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

Repo.insert_all(Buku, [
  %{judul: "Pengantar Logika", pengarang: "Citra Kusuma", tahun: 2018},
  %{judul: "Sejarah Maritim Nusantara", pengarang: "Ali Rahman", tahun: 2020},
  %{judul: "Panduan Menanam Cabai", pengarang: "Dewi Anggraini", tahun: 2019},
  %{judul: "Kamus Indonesia-Inggris", pengarang: "Eka Prasetyo", tahun: 2021},
  %{judul: "Dasar Astronomi", pengarang: "Bambang Susilo", tahun: 2019}
])

id_logika = Repo.get_by!(Buku, judul: "Pengantar Logika").id
id_cabai = Repo.get_by!(Buku, judul: "Panduan Menanam Cabai").id
id_kamus = Repo.get_by!(Buku, judul: "Kamus Indonesia-Inggris").id

Repo.insert_all(Peminjaman, [
  %{buku_id: id_logika, anggota: "Budi Santoso", status: "dipinjam"},
  %{buku_id: id_cabai, anggota: "Rina Melati", status: "dipinjam"},
  %{buku_id: id_kamus, anggota: "Fajar Nugroho", status: "kembali"}
])

defmodule Pencarian do
  import Ecto.Query

  def semua_buku do
    from(b in Buku, order_by: [asc: b.tahun, asc: b.judul], select: {b.judul, b.tahun})
  end

  def dasar do
    from(b in Buku, where: b.tahun >= 2019)
  end

  # query dasar disusun bertahap lewat pipeline
  def dasar_judul do
    dasar()
    |> select([b], b.judul)
    |> order_by([b], b.judul)
  end

  def dasar_tersaring do
    dasar_judul()
    |> where([b], b.tahun <= 2020)
  end

  def karya(nama) do
    from(b in Buku, where: b.pengarang == ^nama, select: b.judul)
  end

  def dipinjam do
    from(p in Peminjaman,
      join: b in Buku,
      on: b.id == p.buku_id,
      where: p.status == "dipinjam",
      select: {b.judul, p.anggota},
      order_by: b.judul
    )
  end
end

IO.puts("semua buku (urut tahun, lalu judul):")
Repo.all(Pencarian.semua_buku())
|> Enum.each(fn {judul, tahun} -> IO.puts("  #{tahun}  #{judul}") end)

IO.puts("query dasar (tahun >= 2019):")
Repo.all(Pencarian.dasar_judul())
|> Enum.each(&IO.puts("  " <> &1))

IO.puts("query tersusun (tahun <= 2020):")
Repo.all(Pencarian.dasar_tersaring())
|> Enum.each(&IO.puts("  " <> &1))

pengarang = "Ali Rahman"
IO.puts("karya #{pengarang}:")
Repo.all(Pencarian.karya(pengarang))
|> Enum.each(&IO.puts("  " <> &1))

IO.puts("buku yang sedang dipinjam:")
Repo.all(Pencarian.dipinjam())
|> Enum.each(fn {judul, anggota} -> IO.puts("  #{judul} dipinjam oleh #{anggota}") end)
text Keluaran
semua buku (urut tahun, lalu judul):
  2018  Pengantar Logika
  2019  Dasar Astronomi
  2019  Panduan Menanam Cabai
  2020  Sejarah Maritim Nusantara
  2021  Kamus Indonesia-Inggris
query dasar (tahun >= 2019):
  Dasar Astronomi
  Kamus Indonesia-Inggris
  Panduan Menanam Cabai
  Sejarah Maritim Nusantara
query tersusun (tahun <= 2020):
  Dasar Astronomi
  Panduan Menanam Cabai
  Sejarah Maritim Nusantara
karya Ali Rahman:
  Sejarah Maritim Nusantara
buku yang sedang dipinjam:
  Panduan Menanam Cabai dipinjam oleh Rina Melati
  Pengantar Logika dipinjam oleh Budi Santoso

Skrip ini dijalankan dengan perintah berikut dari folder topiknya:

bash
# menjalankan skrip pencarian
elixir pencarian.exs
text Keluaran
semua buku (urut tahun, lalu judul):
  2018  Pengantar Logika
  2019  Dasar Astronomi
  2019  Panduan Menanam Cabai
  2020  Sejarah Maritim Nusantara
  2021  Kamus Indonesia-Inggris
query dasar (tahun >= 2019):
  Dasar Astronomi
  Kamus Indonesia-Inggris
  Panduan Menanam Cabai
  Sejarah Maritim Nusantara
query tersusun (tahun <= 2020):
  Dasar Astronomi
  Panduan Menanam Cabai
  Sejarah Maritim Nusantara
karya Ali Rahman:
  Sejarah Maritim Nusantara
buku yang sedang dipinjam:
  Panduan Menanam Cabai dipinjam oleh Rina Melati
  Pengantar Logika dipinjam oleh Budi Santoso

Bagian query tersusun memperlihatkan cara kerja komposisi: query dasarnya memuat empat buku terbitan 2019 ke atas, dan penambahan where: b.tahun <= 2020 mempersempit hasilnya menjadi tiga tanpa menulis ulang syarat pertama. Bagian terakhir menunjukkan join/5 yang menggabungkan peminjaman dengan buku melalui syarat on: b.id == p.buku_id, disaring pada status peminjaman yang aktif, lalu dikembalikan sebagai tuple judul dan nama anggota. Baris peminjaman dengan status "kembali" tidak muncul karena syarat where-nya, dan urutan hasilnya mengikuti order_by pada judul.

Kesalahan umum#

Lupa menambahkan import Ecto.Query adalah kegagalan kompilasi yang paling awal dialami: ekspresi from/2 tidak dikenal dan compiler melaporkan fungsi yang tidak terdefinisi. Gejala ini muncul sebelum program berjalan, sehingga perbaikannya cepat, yaitu menambahkan import Ecto.Query pada modul yang membangun query. Modul yang hanya mengeksekusi query melalui Repo.all/1 tidak membutuhkan import karena eksekusi query adalah pemanggilan fungsi biasa.

Interpolasi teks ke dalam query adalah kesalahan yang jauh lebih berbahaya. Menulis where: b.pengarang == "#{nama}" memasukkan nilai ke dalam teks SQL, sehingga nilai yang mengandung tanda kutip dapat mengubah maksud pernyataan, dan bentuk query berubah untuk setiap nilai yang berbeda. Operator pin ^ menghilangkan kedua masalah itu sekaligus karena nilai dikirim sebagai parameter, dan kebiasaan yang benar adalah mengikat setiap nilai dari luar melalui pin tanpa kecuali.

Ringkasan#

  • Query Ecto adalah struct %Ecto.Query{} yang dibentuk from/2 dan disusun bertahap oleh where/3, select/3, dan order_by/3 tanpa mengubah query asalnya.
  • Operator pin ^ mengikat nilai Elixir sebagai parameter query, sehingga nilai tidak pernah dirangkai ke dalam teks SQL.
  • join/5 menggabungkan tabel melalui syarat on, dan hasilnya dibentuk oleh select baik sebagai struct, nilai tunggal, maupun tuple.
  • Pengurutan pada order_by wajib dinyatakan secara eksplisit apabila urutan hasil perlu dipercaya.

Referensi#