Basis data

Akses basis data dengan crystal-db, driver, dan connection pooling.

Daftar isi

crystal-db adalah pustaka akses basis data generik untuk Crystal yang menyediakan satu antarmuka untuk banyak mesin basis data, sedangkan detail koneksi diserahkan kepada driver seperti crystal-sqlite3 dan crystal-pg. Topik ini membangun proyek kecil bernama catatan-suhu yang menyimpan pembacaan sensor suhu ke basis data SQLite, mulai dari pemasangan dependensi, eksekusi query, transaksi, sampai connection pooling, yaitu pengelolaan sekumpulan koneksi yang dipinjam berulang. Driver PostgreSQL dibahas singkat pada bagian akhir sebagai gambaran penggunaan driver lain.

Dependensi proyek basis data#

Akses basis data dimulai dari dua shard yang dideklarasikan pada shard.yml. Shard pertama bernama db memuat antarmuka generik crystal-db, sedangkan shard kedua bernama sqlite3 memuat driver yang menerjemahkan antarmuka tersebut ke basis data SQLite. Nama kunci untuk pustaka antarmukanya adalah db, bukan crystal-db, karena nama shard pada repositorinya memang db. Simpan berkas berikut sebagai shard.yml pada folder proyek.

yaml shard.yml
name: catatan-suhu
version: "0.1.0"

dependencies:
  db:
    github: crystal-lang/crystal-db
  sqlite3:
    github: crystal-lang/crystal-sqlite3

crystal: ">= 1.0.0"

Dependensi dipasang dengan shards install yang dijalankan di folder proyek, yaitu folder tempat shard.yml berada. Blok berikut menghapus sisa pemasangan sebelumnya terlebih dahulu agar contoh selalu berulang dari keadaan bersih.

bash
# bersihkan sisa pemasangan sebelumnya agar contoh berulang dari keadaan bersih
rm -rf lib shard.lock .shards

# pasang dependensi proyek
shards install
text Keluaran
I: Resolving dependencies
I: Fetching https://github.com/crystal-lang/crystal-db.git
I: Fetching https://github.com/crystal-lang/crystal-sqlite3.git
I: Installing db (0.15.0)
I: Installing sqlite3 (0.24.0)
I: Writing shard.lock

Awalan I: menandai baris informasi dari shards. Baris Fetching menunjukkan repositori yang diunduh, baris Installing menunjukkan versi yang terpasang, yaitu db 0.15.0 dan sqlite3 0.24.0, sedangkan baris Writing shard.lock merekam versi persis tersebut. Hasil pemasangan berada di folder lib/ yang dibaca compiler saat require dijalankan.

Membuka koneksi dan menjalankan query#

Koneksi dibuka dengan DB.open yang menerima URI basis data. Skema sqlite3: pada URI menentukan driver yang digunakan, sedangkan sisa URI menunjuk berkas basis data yang dibuat di folder kerja apabila belum ada. Bentuk blok dari DB.open menutup koneksi secara otomatis ketika bloknya selesai, sehingga koneksi tidak tertinggal terbuka. Metode exec menjalankan pernyataan tanpa hasil seperti CREATE TABLE dan INSERT, sedangkan tanda tanya pada teks SQL adalah placeholder yang diisi oleh argumen berikutnya, sehingga nilai tidak pernah digabungkan langsung ke teks SQL.

Berkas berikut membuat tabel suhu, mengisi dua baris, membacanya kembali, lalu menghitung jumlah barisnya.

crystal catatan.cr
require "db"
require "sqlite3"

DB.open "sqlite3:./catatan.db" do |db|
  db.exec "CREATE TABLE IF NOT EXISTS suhu (waktu TEXT, sensor TEXT, nilai INTEGER)"
  db.exec "DELETE FROM suhu"

  db.exec "INSERT INTO suhu VALUES (?, ?, ?)", "07:00", "ruang-utama", 24
  db.exec "INSERT INTO suhu VALUES (?, ?, ?)", "07:00", "gudang", 19

  db.query "SELECT waktu, sensor, nilai FROM suhu ORDER BY sensor" do |rs|
    rs.each do
      waktu = rs.read(String)
      sensor = rs.read(String)
      nilai = rs.read(Int32)
      puts "#{waktu} #{sensor} #{nilai}"
    end
  end

  jumlah = db.scalar("SELECT COUNT(*) FROM suhu")
  puts "Jumlah catatan: #{jumlah}"
end

Pernyataan DELETE FROM suhu mengosongkan tabel sebelum pengisian, sehingga program dapat dijalankan berulang tanpa menduplikasi baris. Metode query menjalankan SELECT dan memberikan objek rs yang bertipe DB::ResultSet kepada blok. Setiap putaran rs.each membaca satu baris, dan rs.read mengambil nilai kolom berurutan sesuai daftar kolom pada SELECT, dengan tipe yang dinyatakan sebagai argumen seperti String dan Int32. Metode scalar mengembalikan nilai kolom pertama dari baris pertama, sehingga cocok untuk query agregat seperti COUNT. Jalankan program berikut.

bash
# kompilasi dan jalankan program akses basis data
crystal run catatan.cr
text Keluaran
07:00 gudang 19
07:00 ruang-utama 24
Jumlah catatan: 2

Dua baris pertama adalah isi tabel yang diurutkan oleh ORDER BY sensor, yaitu gudang terlebih dahulu karena abjadnya lebih awal. Baris terakhir berasal dari db.scalar yang menghitung dua baris hasil INSERT.

Transaksi#

Transaksi mengelompokkan beberapa pernyataan agar semuanya diterapkan bersama atau tidak sama sekali. Metode db.transaction menerima blok yang menerima objek transaksi, dan pernyataan di dalam blok dijalankan melalui tx.connection.exec agar masuk ke transaksi yang sedang berjalan, bukan ke koneksi lain dari pool. Ketika blok selesai tanpa exception, semua pernyataannya di-commit. Ketika exception terjadi di dalam blok, semua pernyataannya di-rollback secara otomatis, kemudian exception dilempar ulang ke pemanggil.

Berkas berikut memindahkan saldo 25 dari ali ke budi dalam satu transaksi, lalu menjalankan transaksi kedua yang sengaja digagalkan di tengah eksekusi.

crystal transaksi.cr
require "db"
require "sqlite3"

DB.open "sqlite3:./rekening.db" do |db|
  db.exec "CREATE TABLE IF NOT EXISTS rekening (nama TEXT, saldo INTEGER)"
  db.exec "DELETE FROM rekening"

  db.exec "INSERT INTO rekening VALUES (?, ?)", "ali", 100
  db.exec "INSERT INTO rekening VALUES (?, ?)", "budi", 50

  db.transaction do |tx|
    tx.connection.exec "UPDATE rekening SET saldo = saldo - 25 WHERE nama = ?", "ali"
    tx.connection.exec "UPDATE rekening SET saldo = saldo + 25 WHERE nama = ?", "budi"
  end
  puts "Setelah commit: saldo budi = #{db.scalar("SELECT saldo FROM rekening WHERE nama = 'budi'")}"

  begin
    db.transaction do |tx|
      tx.connection.exec "UPDATE rekening SET saldo = saldo - 100 WHERE nama = ?", "budi"
      raise "saldo tidak cukup"
    end
  rescue e
    puts "Transaksi dibatalkan: #{e.message}"
  end
  puts "Setelah rollback: saldo budi = #{db.scalar("SELECT saldo FROM rekening WHERE nama = 'budi'")}"
end
bash
# kompilasi dan jalankan program transaksi
crystal run transaksi.cr
text Keluaran
Setelah commit: saldo budi = 75
Transaksi dibatalkan: saldo tidak cukup
Setelah rollback: saldo budi = 75

Baris pertama membuktikan transaksi pertama di-commit, sehingga saldo budi naik dari 50 menjadi 75. Transaksi kedua mengurangi saldo budi sebanyak 100 sebelum melempar exception, tetapi rollback otomatis membatalkan pengurangan itu, sehingga saldo budi tetap 75 pada baris terakhir.

Connection pooling#

Membuka koneksi basis data berbiaya lebih besar daripada menjalankan query, sehingga crystal-db mengelola pool di balik setiap DB.open. Pool menyimpan koneksi yang sudah terbuka dan meminjamkannya ke kode yang membutuhkan, lalu menerimanya kembali saat penggunaan selesai. Perilaku pool diatur melalui parameter pada URI. Nilai bawaannya adalah initial_pool_size sebesar 1, max_pool_size sebesar 0 yang berarti tanpa batas, dan checkout_timeout sebesar 5.0 detik.

Berkas berikut membatasi pool menjadi dua koneksi dengan max_pool_size=2 dan memperpendek batas tunggu menjadi 0.5 detik. Metode using_connection meminjam satu koneksi dan mengembalikannya ketika bloknya selesai.

crystal pool.cr
require "db"
require "sqlite3"

DB.open "sqlite3:./pool.db?max_pool_size=2&checkout_timeout=0.5" do |db|
  db.exec "CREATE TABLE IF NOT EXISTS pengaturan (kunci TEXT, nilai TEXT)"

  db.using_connection do |c1|
    db.using_connection do |c2|
      puts "Dua koneksi dipinjam bersamaan: #{!c1.same?(c2)}"
    end
  end

  db.using_connection do |c1|
    db.using_connection do |c2|
      begin
        db.using_connection do |c3|
          puts "Koneksi ketiga diperoleh"
        end
      rescue e : DB::PoolTimeout
        puts "#{e.class}: #{e.message}"
      end
    end
  end
end
bash
# kompilasi dan jalankan program pool koneksi
crystal run pool.cr
text Keluaran
Dua koneksi dipinjam bersamaan: true
DB::PoolTimeout: Could not check out a connection in 0.5 seconds

Baris pertama membuktikan dua koneksi dipinjam secara bersamaan, karena !c1.same?(c2) bernilai true hanya apabila keduanya objek yang berbeda. Pada baris kedua, koneksi ketiga diminta ketika kedua koneksi masih dipinjam, sehingga permintaan menunggu sampai checkout_timeout terlampaui dan exception DB::PoolTimeout dilempar.

Driver PostgreSQL#

Driver PostgreSQL untuk crystal-db adalah crystal-pg yang diambil dari repositori will/crystal-pg. Pola pemakaiannya sama dengan driver SQLite, yaitu dependensi ditambahkan ke shard.yml, pustakanya di-require dengan require "pg", lalu URI koneksi menggunakan skema postgres:// yang memuat pengguna, kata sandi, host, port, dan nama basis data. Metode exec, query, scalar, dan transaction tidak berubah karena semuanya disediakan oleh crystal-db, sedangkan driver hanya menyediakan koneksi dan penerjemahan tipe data. Sebagian driver juga menerjemahkan placeholder tanda tanya ke format parameternya masing-masing.

crystal Contoh: koneksi PostgreSQL dengan crystal-pg
# tidak-dijalankan
require "pg"

DB.open "postgres://budi:rahasia@localhost:5432/catatan" do |db|
  db.exec "INSERT INTO suhu VALUES (?, ?, ?)", "07:00", "ruang-utama", 24
  puts db.scalar("SELECT COUNT(*) FROM suhu")
end

Kesalahan umum#

  • Kompilasi berhenti dengan pesan Error: can't find file 'db'. Dependensi belum dipasang atau compiler dijalankan di luar folder proyek. Jalankan shards install di folder yang memuat shard.yml sebelum kompilasi.
  • shards menolak shard.yml dengan pesan E: Error shard name (db) doesn't match dependency name (crystal-db). Penyebabnya adalah dependensi dituliskan dengan kunci crystal-db, sedangkan nama shard pada repositorinya adalah db. Gunakan kunci db pada bagian dependencies.
  • Program berhenti dengan pesan no such table: suhu (SQLite3::Exception). Tabel belum dibuat pada basis data yang ditunjuk URI. Jalankan CREATE TABLE, misalnya dengan bentuk IF NOT EXISTS, sebelum query yang menyebut tabel itu.
  • Program diam selama lima detik lalu melempar DB::PoolTimeout. Pool kehabisan koneksi dan checkout_timeout belum diatur, sehingga nilai bawaan 5.0 detik digunakan. Atur checkout_timeout pada URI atau pastikan setiap koneksi dikembalikan, misalnya dengan menutup blok using_connection.

Ringkasan#

  • crystal-db menyediakan antarmuka generik berupa DB.open, exec, query, scalar, dan transaction, sedangkan driver seperti crystal-sqlite3 dan crystal-pg menyediakan implementasinya.
  • Placeholder tanda tanya mengisi parameter query dengan aman, dan rs.read membaca kolom hasil query secara berurutan dengan tipe yang dinyatakan.
  • db.transaction melakukan commit di akhir blok dan rollback otomatis apabila exception terjadi di dalamnya.
  • Pool diatur melalui parameter URI seperti max_pool_size dan checkout_timeout, dan melempar DB::PoolTimeout apabila koneksi tidak diperoleh sebelum batas waktu.

Referensi#