Menyimpan dan mengambil data di Haskell: SQL langsung dengan postgresql-simple, ORM type-safe dengan Persistent, query SQL type-safe dengan Esqueleto, mengelola koneksi lewat connection pool berbasis resource-pool, serta migration skema yang dapat diverifikasi dengan persistent-migration.

Backend HTTP di episode 13 tidak akan lengkap tanpa cara menyimpan data. Di episode 15 ini kita membahas persistence: menghubungkan aplikasi Haskell ke database — khususnya PostgreSQL — dengan tiga pendekatan yang saling melengkapi. Pilihan di sini bukan soal "mana yang terbaik" secara absolut, melainkan "mana yang tepat untuk kebutuhan apa".
Mengapa bab ini penting? Karena kesalahan paling mahal di persistence adalah query yang tidak valid baru terdeteksi saat runtime. Di dunia SQL biasa, typo nama kolom adalah bom waktu: aplikasi tampak sehat sampai sebuah request memicunya. Haskell menawarkan pilihan untuk mengangkat masalah ini ke compile time — dan episode ini menunjukkan caranya.
postgresql-simple adalah lapisan tipis di atas libpq — kalian menulis SQL asli, tapi dengan helper yang aman untuk parameter binding. Contoh paling dasar:
import Database.PostgreSQL.Simple
data User = User { uid :: Int, uname :: String }
deriving (Show)
main :: IO ()
main = do
conn <- connect defaultConnectInfo
{ connectHost = "localhost"
, connectDatabase = "appdb"
, connectUser = "app"
, connectPassword = "secret"
}
users <- query_ conn "SELECT id, name FROM users" :: IO [User]
mapM_ print usersquery_ menjalankan query tanpa parameter dan memetakan hasil ke tipe via instance FromRow. Untuk query berparameter — hindari interpolasi string mentah:
cariUser :: Connection -> String -> IO (Maybe User)
cariUser conn nama = do
hasil <- query conn "SELECT id, name FROM users WHERE name = ?" (Only nama)
pure (case hasil of
[] -> Nothing
(u:_) -> Just u)Tanda ? adalah placeholder yang di-binding aman oleh driver — kalian tidak pernah menyambung string SQL dengan input pengguna (dasar keamanan yang kita perdalam di episode 17).
Warning
Jangan pernah membangun query dengan concatenation string, misalnya "WHERE name = '" ++ nama ++ "'". Selain raw SQL injection, cara ini juga membuat kode tidak bisa diparameterisasi ulang dan sulit dites. ? placeholder di postgresql-simple (atau :x di library lain) selalu lebih aman.
Persistent (dari ekosistem Yesod) memodelkan skema di level tipe, dan skema itulah satu-satunya sumber kebenaran. Skema didefinisikan dengan sintaks khusus:
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
import Database.Persist.Sql
import Database.Persist.TH
mkPersist sqlSettings [persistLowerCase|
User
name String
email String Maybe
deriving Show
|]Persistent menghasilkan tipe User, tabel user, dan fungsi-fungsi query type-safe. Query dilakukan dengan combinator, bukan string SQL:
import Database.Persist.Sqlite
main :: IO ()
main = runSqlite "app.db" $ do
uid <- insert (User "devnull" (Just "devnull@example.com"))
user <- get uid
list <- selectList [UserEmail <-. Just "devnull@example.com"] [LimitTo 10]
print (user, list)Keunggulan utama: skema dan query diperiksa compiler. Rename kolom di satu tempat, dan semua query di seluruh codebase menyesuaikan otomatis — compiler menuntun setiap lokasi yang terdampak.
Persistent unggul untuk CRUD sederhana. Untuk query yang butuh JOIN, group by, dan subquery, Esqueleto menyediakan EDSL SQL yang type-safe: query ditulis hampir seperti SQL, tetapi nama tabel/kolom diperiksa compiler.
import Database.Esqueleto
penggunaDenganPosting :: SqlPersistT IO [(Entity User, Entity Post)]
penggunaDenganPosting = select $ from $ \(u `InnerJoin` p) -> do
on (u ^. UserId ==. p ^. PostUserId)
where_ (u ^. UserName ==. val "devnull")
orderBy [asc (u ^. UserName)]
pure (u, p)Perhatikan sintaks: u ^. UserId adalah akses kolom, ==. adalah perbandingan kolom, val membungkus nilai literal. Query ini tidak mungkin mengacu kolom yang tidak ada — typos berhenti di kompilasi. Inilah jawaban "SQL murni yang diperiksa tipe".
Membuka koneksi database per request sangat mahal. Solusi standar: connection pool — kumpulan koneksi yang dipakai bersama dan dipertahankan hidup. Paket resource-pool adalah fondasi pooling di Haskell (digunakan Persistent secara internal):
import Data.Pool
import Database.PostgreSQL.Simple
buatPool :: IO (Pool Connection)
buatPool = createPool
(connect defaultConnectInfo) -- buat koneksi
close -- tutup koneksi
10 -- jumlah koneksi di pool
30 -- lama idle sebelum ditutup (detik)
20 -- maksimal koneksi per resource
denganKoneksi :: Pool Connection -> (Connection -> IO a) -> IO a
denganKoneksi pool aksi = withResource pool aksiwithResource meminjam koneksi, menjalankan aksi, dan mengembalikannya ke pool — bahkan saat aksi melempar exception. Dengan pool, aplikasi menangani ratusan request bersamaan hanya dengan belasan koneksi.
Skema database berkembang seiring fitur. Migration mencatat perubahan skema secara terprogram dan menerapkannya dengan aman. Persistent menyediakan runMigration:
import Database.Persist.Sqlite
import Database.Persist.Sql (runMigration)
main :: IO ()
main = runSqlite "app.db" $ do
runMigration migrateAllrunMigration membandingkan skema di kode dengan database aktual dan menambahkan tabel/kolom yang belum ada. Untuk tim produksi, gunakan persistent-migration yang menyimpan riwayat migration seperti versi — setiap perubahan skema ditulis sebagai langkah bernomor yang dieksekusi sekali, diuji, dan di-rollback bila perlu. Ini mencegah "skema berbeda di tiap environment".
Tip
Pilihan praktis: postgresql-simple untuk query yang sudah jelas dan proyek kecil; Persistent sebagai default untuk skema aplikasi (skema diperiksa compiler); Esqueleto di atas Persistent ketika JOIN dan agregasi kompleks diperlukan. Kombinasi Persistent + Esqueleto adalah kombinasi paling populer di kode produksi Haskell.
Terapkan pola yang sudah familier:
type App a = SqlPersistT IO a
-- fungsi pure: logika tanpa database
berhakLogin :: Bool -> Bool -> Bool
berhakLogin aktif diBanned = aktif && not diBanned
-- fungsi impure: akses database
ambilUser :: UserId -> App (Maybe User)
ambilUser = getLogika (pure) dan akses data (impure) dipisah: test logika tanpa database, dan mock hanya lapisan repository. Ini menjaga kode tetap testable sekaligus memakai kekuatan tipe untuk kontrak.
Maybe untuk kolom NOT NULL — Persistent mendeteksi ini, tapi pastikan skema mencerminkan domain (kolom wajib = tanpa Maybe).Inti yang harus dibawa pulang:
resource-pool) menjaga koneksi tetap hidup dan dibagi.Di episode 16 selanjutnya kita akan membahas error handling & exception — membedakan error recoverable dengan Maybe/Either dan ExceptT/MonadError, menangani exception I/O dengan Control.Exception dan bracket, serta menggantikan fungsi partial seperti head dan fromJust dengan fungsi total. Sampai jumpa di episode 16!