Database di Rust: SQLite dan Postgres dengan sqlx, Diesel, dan SeaORM
Cepat atau lambat, programmu perlu mengingat sesuatu: user, pesanan, skor tertinggi, daftar to-do. Begitu program berhenti, isi Vec atau HashMap ikut hilang. Database menyimpan data dengan aman di disk, bisa dipakai bersama oleh banyak program, dan menjawab pertanyaan seperti “tampilkan semua tugas yang belum selesai, yang terbaru dulu” dengan sangat cepat.
Rust punya crate database yang sangat bagus. Di halaman ini kamu akan memilih salah satunya, lalu menulis kode sungguhan: sebuah “repository” kecil dengan Rust biasa (bisa langsung dijalankan di sini, di Playground), lalu SQLite dengan sqlx, Postgres, migration, dan tur singkat Diesel serta SeaORM. Crate database tidak tersedia di Playground, jadi contoh-contoh itu dijalankan di komputermu sendiri dengan cargo run. Semua contoh sudah di-build dan dijalankan, dan output yang ditampilkan adalah hasil asli.
Apa yang bisa kamu buat
Section titled “Apa yang bisa kamu buat”- Lapisan data (data layer) untuk web API. Ambil server to-do dari Proyek: Membuat Web API dengan Axum, lalu simpan datanya di Postgres, bukan di memori.
- Website besar yang sibuk. crates.io, situs tempat semua crate Rust disimpan, menyimpan datanya di Postgres lewat Diesel. Begitu juga Lemmy, platform komunitas open-source.
- Tool desktop dan command-line dengan file SQLite lokal: aplikasi catatan, pencatat anggaran, cache.
- Background worker dan job pengolahan data yang membaca baris (row), memprosesnya, lalu menulis hasilnya kembali.
Bagian yang lebih sulit: Rust tidak punya satu stack “resmi” seperti Entity Framework di .NET atau ActiveRecord di Rails, jadi kamu memilih di antara beberapa crate yang bagus. Build pertama crate database makan waktu satu atau dua menit. Dan SQL yang dicek saat compile (fitur yang keren) butuh sedikit persiapan.
Crate mana yang dipilih?
Section titled “Crate mana yang dipilih?”| Crate | Sync atau async | Query dicek saat compile? | ORM? | Pilih kalau |
|---|---|---|---|---|
| rusqlite | sync | tidak | tidak, SQL biasa | Tool kecil atau aplikasi desktop dengan satu file SQLite. Tidak butuh async. |
| sqlx | async | ya, dengan makro query! (opsional) |
tidak, SQL biasa | Kamu suka menulis SQL dan ingin async. SQLite, Postgres, MySQL. Pilihan pertama yang paling umum untuk aplikasi web. |
| Diesel | sync (diesel-async menambahkan async) |
ya, seluruh query builder-nya dicek tipenya | ORM dan query builder | Kamu ingin compiler mengecek setiap query dan tidak keberatan mempelajari gaya query-nya. Sangat matang. |
| SeaORM | async (ada sea-orm-sync) |
tipe ya, SQL tidak (dicek saat berjalan) | ya, ORM lengkap | Kamu ingin entity, relasi, dan “simpan objek ini” seperti di Entity Framework. Dibangun di atas sqlx. |
Keempatnya sudah matang dan banyak dipakai. Kalau ragu, mulai dengan sqlx: kamu menulis SQL biasa, jadi ilmunya bisa dipakai di bahasa apa pun. Datang dari C#? Halaman Rust untuk Developer C# memetakan Entity Framework, Dapper, dan ADO.NET ke crate-crate ini.
Bentuknya dulu: repository dengan Rust biasa
Section titled “Bentuknya dulu: repository dengan Rust biasa”Sebelum menyentuh database, mari lihat pola yang dipakai hampir semua aplikasi: repository. Ini adalah satu tempat yang tahu cara menyimpan dan memuat datamu. Bagian program yang lain cukup memanggil method seperti add dan list_open, tanpa peduli apakah datanya ada di HashMap, di SQLite, atau di Postgres.
Di Rust, sebuah trait menjelaskan apa saja yang bisa dilakukan repository, dan sebuah struct yang mengerjakannya. Di sini penyimpanannya berupa HashMap di memori:
use std::collections::HashMap;
#[derive(Debug, Clone)]struct Task { id: u32, title: String, done: bool,}
// Apa yang dibutuhkan aplikasi dari penyimpanan. Tidak ada yang menjelaskan BAGAIMANA data disimpan.trait TaskRepository { fn add(&mut self, title: &str) -> u32; fn mark_done(&mut self, id: u32) -> Result<(), String>; fn list_open(&self) -> Vec<Task>;}
struct InMemoryRepo { tasks: HashMap<u32, Task>, next_id: u32,}
impl TaskRepository for InMemoryRepo { fn add(&mut self, title: &str) -> u32 { let id = self.next_id; self.tasks.insert(id, Task { id, title: title.to_string(), done: false }); self.next_id += 1; id }
fn mark_done(&mut self, id: u32) -> Result<(), String> { match self.tasks.get_mut(&id) { Some(task) => { task.done = true; Ok(()) } None => Err(format!("tidak ada tugas dengan id {id}")), } }
fn list_open(&self) -> Vec<Task> { let mut open: Vec<Task> = self.tasks.values().filter(|t| !t.done).cloned().collect(); open.sort_by_key(|t| t.id); // HashMap tidak punya urutan, jadi kita urutkan open }}
// Bisa dipakai dengan repository APA PUN: di memori hari ini, SQLite atau Postgres besok.fn print_open(repo: &impl TaskRepository) { for task in repo.list_open() { println!("[ ] {}: {}", task.id, task.title); }}
fn main() { let mut repo = InMemoryRepo { tasks: HashMap::new(), next_id: 1 }; repo.add("Beli susu"); repo.add("Belajar sqlx"); repo.add("Ajak anjing jalan-jalan");
repo.mark_done(1).unwrap(); print_open(&repo); println!("{:?}", repo.mark_done(99));}Output:
[ ] 2: Belajar sqlx[ ] 3: Ajak anjing jalan-jalanErr("tidak ada tugas dengan id 99")Perhatikan apa yang nanti diberikan database di sini. HashMap menjadi sebuah tabel, setiap Task menjadi sebuah baris (row), dan next_id menjadi kolom id yang bernomor otomatis. list_open menjadi query dengan WHERE done = FALSE ORDER BY id. Versi di memori ini juga berguna nanti: ia bisa jadi tiruan (fake) yang cepat untuk tes-mu.
sqlx dengan SQLite: database sungguhan pertamamu
Section titled “sqlx dengan SQLite: database sungguhan pertamamu”SQLite adalah database utuh di dalam satu file (atau cukup di memori). Tidak ada server yang perlu dipasang, jadi sangat cocok untuk belajar, untuk tes, dan untuk banyak aplikasi sungguhan. Buat proyek lalu tambahkan sqlx dan tokio:
cargo new todo-dbcd todo-dbcargo add sqlx --features runtime-tokio,sqlitecargo add tokio --features full[dependencies]sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] }tokio = { version = "1", features = ["full"] }Fitur sqlite memasukkan SQLite langsung ke dalam programmu, jadi kamu tidak perlu memasang apa-apa lagi. sqlx itu async, karena itulah ia butuh tokio. Sekarang ganti isi src/main.rs:
use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]struct Task { id: i64, title: String, done: bool,}
#[tokio::main]async fn main() -> Result<(), sqlx::Error> { // Database di memori: pas untuk belajar, hilang saat program selesai. let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::query( "CREATE TABLE tasks ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE )", ) .execute(&pool) .await?;
for title in ["Buy milk", "Learn sqlx", "Walk the dog"] { let result = sqlx::query("INSERT INTO tasks (title) VALUES (?)") .bind(title) .execute(&pool) .await?; println!("Inserted task {}", result.last_insert_rowid()); }
sqlx::query("UPDATE tasks SET done = TRUE WHERE id = ?") .bind(1_i64) .execute(&pool) .await?;
let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks ORDER BY id") .fetch_all(&pool) .await?;
for task in &tasks { let mark = if task.done { "x" } else { " " }; println!("[{mark}] {}: {}", task.id, task.title); }
let open: Option<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE id = ?") .bind(42_i64) .fetch_optional(&pool) .await?; println!("Task 42: {open:?}");
Ok(())}Jalankan dengan cargo run:
Inserted task 1Inserted task 2Inserted task 3[x] 1: Buy milk[ ] 2: Learn sqlx[ ] 3: Walk the dogTask 42: None(Contoh-contoh yang dijalankan di komputermu sendiri sengaja dibiarkan dengan teks bahasa Inggris, supaya kode dan output-nya sama persis dengan yang sudah kami jalankan.)
Langkah demi langkah:
sqlx::query("...")menyimpan SQL-nya. Setiap?adalah placeholder, dan.bind(value)mengisinya, sesuai urutan..execute(&pool)menjalankan perintah yang tidak mengembalikan baris (CREATE,INSERT,UPDATE,DELETE). Hasilnya memberi tahurows_affected(), dan khusus SQLite jugalast_insert_rowid().#[derive(sqlx::FromRow)]membuat sqlx bisa mengubah setiap baris menjadiTask. Nama kolom harus sama dengan nama field.query_as+fetch_allmenghasilkanVec<Task>. Pakaifetch_onekalau barisnya harus tepat satu, danfetch_optionalkalau mungkin tidak ada sama sekali. Perhatikan bahwa baris yang tidak ada menjadiNone, sebuahOption, bukan crash.- Setiap panggilan mengembalikan
Result, jadi?meneruskan error database kemain.
Supaya datanya tetap ada setelah program selesai, pakai file: SqlitePool::connect("sqlite:todos.db?mode=rwc"). Bagian mode=rwc artinya “baca, tulis, dan buat file-nya kalau belum ada”.
Query yang dicek saat compile. sqlx juga punya makro seperti sqlx::query_as!(Task, "SELECT id, title, done FROM tasks WHERE done = ?", done). Saat build, makro ini terhubung ke database development-mu (dari DATABASE_URL) dan mengecek SQL-nya. Salah ketik seperti titel akan menghentikan build dengan pesan no such column: titel, sebelum ada user yang melihatnya. Ini butuh database yang sudah berisi tabelmu selama build, jadi mulailah dengan fungsi query biasa, lalu tambahkan makronya setelah kamu punya migration (lihat di bawah).
Terhubung ke Postgres
Section titled “Terhubung ke Postgres”Postgres (PostgreSQL) adalah pilihan paling populer untuk aplikasi web. Postgres berjalan sebagai server, jadi kamu butuh server yang menyala: pasang dari postgresql.org, pakai database cloud, atau jalankan dengan Docker:
docker run --name todo-pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgresKodenya nyaris tidak berubah. Nyalakan fitur postgres (tambahkan fitur TLS seperti tls-rustls untuk database cloud, yang biasanya mewajibkan enkripsi):
cargo add sqlx --features runtime-tokio,postgres,tls-rustlsLalu terhubung dengan connection string, sebuah URL yang menjelaskan di mana database-nya dan cara login ke sana:
postgres://USER:PASSWORD@HOST:PORT/DATABASEpostgres://postgres:secret@localhost:5432/postgresJangan pernah menulis password di dalam kode. Taruh URL-nya di environment variable DATABASE_URL dan baca saat program mulai (lihat Environment Variable dan Proses):
use sqlx::postgres::PgPoolOptions;use std::time::Duration;
let url = std::env::var("DATABASE_URL").expect("set DATABASE_URL first");
let pool = PgPoolOptions::new() .max_connections(5) .acquire_timeout(Duration::from_secs(3)) .connect(&url) .await?;
// Postgres memberi nomor pada placeholder-nya: $1, $2, ...let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = $1") .bind(false) .fetch_all(&pool) .await?;Dua perbedaan dari SQLite yang perlu diingat: placeholder Postgres adalah $1, $2, bukan ?, dan id yang bernomor otomatis biasanya berupa BIGINT GENERATED ALWAYS AS IDENTITY (atau BIGSERIAL), yang cocok dengan i64. Kalau password-mu mengandung karakter seperti @ atau /, ubah dengan percent-encoding di URL (@ menjadi %40). Tool seperti sqlx-cli juga membaca DATABASE_URL dari file .env di folder proyekmu. Tambahkan .env ke .gitignore supaya file itu tidak pernah ikut ter-upload ke GitHub.
Migration dengan sqlx-cli
Section titled “Migration dengan sqlx-cli”Tabelmu akan berubah seiring waktu: kolom baru di sini, tabel baru di sana. Migration adalah file SQL bernomor yang menjelaskan setiap perubahan. Setiap database (milikmu, milik rekan setim, production) menjalankan file yang sama dengan urutan yang sama, dan database mengingat mana saja yang sudah dijalankan. Pasang tool command-line-nya sekali saja:
# all databasescargo install sqlx-cli
# or only what you need, for a faster buildcargo install sqlx-cli --no-default-features --features sqlite,rustlsBaris pertama memasang dukungan untuk semua database. Baris kedua hanya memasang yang kamu butuhkan (SQLite dan TLS), jadi build-nya lebih cepat. Lalu, di folder proyekmu:
echo "DATABASE_URL=sqlite:todos.db" > .envsqlx database createsqlx migrate add create_tasksCreating migrations/20261018035858_create_tasks.sql(Angkamu akan berbeda: itu tanggal dan jam saat file dibuat.) Tulis perubahannya ke file itu:
CREATE TABLE tasks ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE);Lalu terapkan:
sqlx migrate runApplied 20261018035858/migrate create tasks (1.449995ms)Kalau dijalankan lagi, tidak terjadi apa-apa, karena migration itu sudah diterapkan. sqlx migrate info menampilkan mana yang sudah diterapkan dan mana yang masih menunggu. Jangan pernah mengedit migration yang sudah pernah dijalankan di suatu tempat; buat migration baru saja.
Kamu juga bisa membiarkan programmu menerapkan migration saat mulai berjalan. Makro migrate! memasukkan file-file dari ./migrations ke dalam binary-mu:
let pool = SqlitePool::connect("sqlite::memory:").await?;sqlx::migrate!().run(&pool).await?; // hanya menjalankan migration yang belum pernah dilihat database iniSekilas tentang Diesel
Section titled “Sekilas tentang Diesel”Diesel mengambil jalan yang berbeda: alih-alih menulis string SQL, kamu menyusun query dengan method Rust, dan compiler mengeceknya terhadap deskripsi tabel-tabelmu. Misalnya, done adalah kolom bool, jadi tasks::done.eq(true) bisa di-compile, sedangkan tasks::done.eq("yes") adalah error compile. Diesel bersifat synchronous, dan itu tidak masalah untuk tool dan untuk banyak server.
[dependencies]diesel = { version = "2.3", features = ["sqlite"] }# Builds SQLite into your program. Only needed if you don't have SQLite installed:libsqlite3-sys = { version = "0.38", features = ["bundled"] }Baris libsqlite3-sys dengan fitur bundled memasukkan SQLite ke dalam programmu. Kamu hanya butuh baris itu kalau SQLite belum terpasang di komputermu.
use diesel::prelude::*;use diesel::sqlite::SqliteConnection;
// Biasanya `diesel migration run` yang membuat kode ini untukmu di src/schema.rs.diesel::table! { tasks (id) { id -> Integer, title -> Text, done -> Bool, }}
#[derive(Queryable, Selectable, Debug)]#[diesel(table_name = tasks)]#[diesel(check_for_backend(diesel::sqlite::Sqlite))]struct Task { id: i32, title: String, done: bool,}
#[derive(Insertable)]#[diesel(table_name = tasks)]struct NewTask<'a> { title: &'a str,}
fn main() -> Result<(), Box<dyn std::error::Error>> { let mut conn = SqliteConnection::establish(":memory:")?;
// Biasanya ini tugas migration. diesel::sql_query( "CREATE TABLE tasks ( id INTEGER PRIMARY KEY NOT NULL, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE )", ) .execute(&mut conn)?;
let new_tasks = [NewTask { title: "Buy milk" }, NewTask { title: "Learn Diesel" }]; diesel::insert_into(tasks::table) .values(&new_tasks[..]) .execute(&mut conn)?;
diesel::update(tasks::table.find(1)) .set(tasks::done.eq(true)) .execute(&mut conn)?;
let all: Vec<Task> = tasks::table .order(tasks::id) .select(Task::as_select()) .load(&mut conn)?; for task in &all { println!("{} {} done={}", task.id, task.title, task.done); }
let open: i64 = tasks::table .filter(tasks::done.eq(false)) .count() .get_result(&mut conn)?; println!("{open} task(s) still open"); Ok(())}1 Buy milk done=true2 Learn Diesel done=false1 task(s) still openDi proyek sungguhan, kamu memasang diesel_cli (cargo install diesel_cli --no-default-features --features sqlite), lalu menjalankan diesel setup, diesel migration generate create_tasks, dan diesel migration run. CLI ini menulis src/schema.rs berisi kode table! untukmu, jadi isinya selalu cocok dengan database. Panduan Getting Started (bahasa Inggris) membahas semuanya.
Sekilas tentang SeaORM
Section titled “Sekilas tentang SeaORM”SeaORM adalah ORM async: kamu mendeskripsikan setiap tabel sebagai entity (sebuah struct dengan atribut), lalu bekerja dengan objek, bukan SQL. Rasanya mirip Entity Framework. Versi 2.0 bahkan bisa membuat tabel langsung dari entity-mu.
[dependencies]sea-orm = { version = "2", features = ["sqlx-sqlite", "runtime-tokio", "entity-registry", "schema-sync"] }tokio = { version = "1", features = ["full"] }mod task { use sea_orm::entity::prelude::*;
#[sea_orm::model] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "task")] pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub title: String, pub done: bool, }
impl ActiveModelBehavior for ActiveModel {}}
use sea_orm::{entity::*, query::*, Database, DbErr};
#[tokio::main]async fn main() -> Result<(), DbErr> { let db = &Database::connect("sqlite::memory:").await?;
// Buat tabel untuk setiap entity di crate ini. "seaorm_demo" adalah // nama package dari Cargo.toml, dengan - diganti menjadi _. db.get_schema_registry("seaorm_demo::*").sync(db).await?;
for title in ["Buy milk", "Learn SeaORM"] { task::ActiveModel { title: Set(title.to_owned()), done: Set(false), ..Default::default() } .insert(db) .await?; }
let mut milk = task::Entity::find_by_id(1).one(db).await?.unwrap().into_active_model(); milk.done = Set(true); milk.update(db).await?;
let open = task::Entity::find() .filter(task::Column::Done.eq(false)) .all(db) .await?; for t in &open { println!("open: {} {}", t.id, t.title); } Ok(())}open: 2 Learn SeaORMModel adalah baris yang kamu baca. ActiveModel adalah baris yang sedang kamu ubah: Set(...) menandai field yang akan disimpan, dan ..Default::default() membiarkan id diisi oleh database. Untuk proyek yang lebih besar, sea-orm-cli bisa membuat entity dari database yang sudah ada dan mengelola migration. Lihat dokumentasi SeaORM (bahasa Inggris).
SQL injection: selalu pakai bind parameter
Section titled “SQL injection: selalu pakai bind parameter”Ini aturan paling penting di halaman ini. Jangan pernah menyusun SQL dengan menempelkan input user ke dalam string memakai format!. Ini alasannya, dengan Rust biasa (tanpa database):
// Apa yang diterima database, disusun dengan dua cara berbeda.fn pasted_query(name: &str) -> String { format!("SELECT * FROM users WHERE name = '{name}'")}
fn bound_query(name: &str) -> (&'static str, Vec<String>) { ("SELECT * FROM users WHERE name = ?", vec![name.to_string()])}
fn main() { for input in ["ana", "x' OR '1'='1"] { println!("input: {input}"); println!(" pasted: {}", pasted_query(input)); let (sql, params) = bound_query(input); println!(" bound: {sql} params: {params:?}"); }}Output:
input: ana pasted: SELECT * FROM users WHERE name = 'ana' bound: SELECT * FROM users WHERE name = ? params: ["ana"]input: x' OR '1'='1 pasted: SELECT * FROM users WHERE name = 'x' OR '1'='1' bound: SELECT * FROM users WHERE name = ? params: ["x' OR '1'='1"]Label pasted (ditempel) dan bound (di-bind) sengaja dibiarkan dalam bahasa Inggris, karena spasinya diatur supaya kedua baris SQL sejajar.
Dengan cara menempel, tanda kutip di input mengakhiri string terlalu cepat, dan OR '1'='1' menjadi bagian dari SQL. Kondisi itu selalu bernilai true, jadi query-nya mengembalikan semua user. Penyerang bisa memakai trik yang sama untuk membaca data orang lain atau menghapus tabel. Ini disebut SQL injection, dan termasuk bug keamanan yang paling umum di web.
Dengan bind parameter, teks SQL-nya tidak pernah berubah. Input-nya dikirim terpisah, dan database memperlakukannya hanya sebagai nilai. Berikut kedua versi itu dengan sqlx, dijalankan pada tabel SQLite sungguhan yang berisi user ana dan root:
let user_input = "nobody' OR '1'='1"; // bayangkan ini datang dari form login
// JANGAN: menempelkan input ke teks SQL.let sql = format!("SELECT name FROM users WHERE name = '{user_input}'");let rows: Vec<(String,)> = sqlx::query_as(AssertSqlSafe(sql)).fetch_all(&pool).await?;println!("format!: {rows:?}");
// LAKUKAN: teks SQL yang tetap dengan placeholder ?, dan input dikirim terpisah.let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM users WHERE name = ?") .bind(user_input) .fetch_all(&pool) .await?;println!("bind: {rows:?}");format!: [("ana",), ("root",)]bind: []Query yang ditempel membocorkan kedua user. Query yang di-bind dengan benar tidak menemukan siapa pun dengan nama aneh itu. Perhatikan juga pembungkus AssertSqlSafe(...) di versi yang buruk: sqlx 0.9 melindungimu secara default. sqlx::query dan query_as hanya menerima &'static str yang tetap, jadi memberikan string hasil format! tanpa pembungkus itu akan gagal di-compile:
error[E0277]: dynamic SQL strings should be audited for possible injections = help: the trait `SqlSafeStr` is not implemented for `&std::string::String` = note: prefer literal SQL strings with bind parameters or `QueryBuilder` to add dynamic data to a query.Pesan compiler ini artinya: string SQL yang dinamis harus diperiksa dulu dari kemungkinan injection, dan sebaiknya pakai SQL literal dengan bind parameter atau QueryBuilder.
Jadi kapan kamu perlu AssertSqlSafe? Hanya kalau kamu benar-benar harus menyusun SQL saat program berjalan, misalnya kolom pengurutan yang dipilih user. Cek dulu nilainya terhadap daftar nama yang diizinkan buatanmu sendiri, baru bungkus string-nya untuk memberi tahu sqlx bahwa kamu sudah memeriksanya. Untuk nilai, selalu pakai .bind(). Diesel dan SeaORM mem-bind nilai untukmu, selama kamu memakai query builder mereka dan bukan string SQL mentah.
Connection pool
Section titled “Connection pool”Membuka koneksi database itu lambat: ada perjalanan lewat jaringan, login, dan sedikit persiapan. Sebuah pool membuka beberapa koneksi sekali saja, lalu meminjamkannya. Kodemu meminjam satu koneksi untuk satu query dan langsung mengembalikannya. Itulah kenapa semua contoh di atas memberikan &pool, bukan satu koneksi.
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
let options = SqliteConnectOptions::new() .filename("visits.db") .create_if_missing(true);
let pool = SqlitePoolOptions::new() .max_connections(4) // tidak pernah lebih dari 4 sekaligus .connect_with(options) .await?;
sqlx::query("CREATE TABLE IF NOT EXISTS visits (page TEXT NOT NULL)") .execute(&pool) .await?;
// 20 task berbagi satu pool. Meng-clone pool itu murah: isinya cuma sebuah handle.let mut handles = Vec::new();for n in 0..20 { let pool = pool.clone(); handles.push(tokio::spawn(async move { sqlx::query("INSERT INTO visits (page) VALUES (?)") .bind(format!("/page/{}", n % 3)) .execute(&pool) .await }));}for handle in handles { handle.await.expect("task panicked")?; // tunggu sampai ke-20 insert selesai}Ke-20 task berjalan bersamaan, tapi paling banyak 4 yang berbicara dengan database pada satu waktu. Sisanya menunggu sebentar sampai ada koneksi yang bebas. Menghitung baris setelahnya (dengan visits.db yang masih baru) menghasilkan:
/page/0: 7 visits/page/1: 7 visits/page/2: 6 visitsBeberapa patokan praktis:
- Buat satu pool saat program mulai, lalu bagikan. Di aplikasi axum, taruh pool di app state-mu (lihat proyek web API).
- Jangan membuat pool baru untuk setiap request. Itu membuang seluruh manfaatnya.
max_connectionsharus jauh di bawah batas yang diizinkan database. Server Postgres mengizinkan 100 koneksi secara default, dan setiap salinan aplikasimu punya pool sendiri.- Pasang
acquire_timeout, supaya saat semua koneksi sedang sibuk, request gagal dengan error yang jelas, bukan menunggu selamanya.
Latihan
Section titled “Latihan”1. Cari di repository
Section titled “1. Cari di repository”Ambil ide repository di memori tadi dan tambahkan method search: method ini mengembalikan tugas yang judulnya mengandung sebuah kata, tanpa membedakan huruf besar dan kecil, diurutkan berdasarkan id. Uji dengan kata “susu”. Petunjuk: title.to_lowercase().contains(&word.to_lowercase()), dan Vec<Task> sudah cukup sebagai penyimpanan di sini.
Lihat solusi
#[derive(Debug, Clone)]struct Task { id: u32, title: String,}
trait TaskRepository { fn add(&mut self, title: &str) -> u32; fn search(&self, word: &str) -> Vec<Task>;}
struct VecRepo { tasks: Vec<Task>,}
impl TaskRepository for VecRepo { fn add(&mut self, title: &str) -> u32 { let id = self.tasks.len() as u32 + 1; self.tasks.push(Task { id, title: title.to_string() }); id }
fn search(&self, word: &str) -> Vec<Task> { let word = word.to_lowercase(); let mut found: Vec<Task> = self .tasks .iter() .filter(|t| t.title.to_lowercase().contains(&word)) .cloned() .collect(); found.sort_by_key(|t| t.id); found }}
fn main() { let mut repo = VecRepo { tasks: Vec::new() }; repo.add("Beli susu"); repo.add("Ajak anjing jalan-jalan"); repo.add("Susu untuk kucing");
for task in repo.search("susu") { println!("{}: {}", task.id, task.title); } println!("{} hasil untuk \"sapi\"", repo.search("sapi").len());}Output:
1: Beli susu3: Susu untuk kucing0 hasil untuk "sapi"Penyimpanannya berubah dari HashMap menjadi Vec, tapi kode yang memanggil trait itu tidak perlu tahu. Di SQL, pencarian yang sama ditulis WHERE title LIKE ? dengan .bind(format!("%{word}%")). Perhatikan bahwa tanda % dimasukkan ke nilai yang di-bind, bukan ke teks SQL-nya.
2. Repository sungguhan dengan sqlx (di komputermu)
Section titled “2. Repository sungguhan dengan sqlx (di komputermu)”Tulis struct SqliteTaskRepo yang menyimpan sebuah SqlitePool dan punya tiga method async: add(title) mengembalikan id baru, mark_done(id) mengembalikan true kalau ada baris yang berubah, dan list_open() mengembalikan tugas yang belum selesai. Buat tabelnya di main, tambahkan tiga tugas, lalu tandai tugas 1 dan tugas 99 sebagai selesai. Petunjuk: rows_affected() pada hasil execute memberi tahu berapa baris yang diubah oleh sebuah UPDATE.
Lihat solusi
use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]struct Task { id: i64, title: String, done: bool,}
struct SqliteTaskRepo { pool: SqlitePool,}
impl SqliteTaskRepo { async fn add(&self, title: &str) -> Result<i64, sqlx::Error> { let result = sqlx::query("INSERT INTO tasks (title) VALUES (?)") .bind(title) .execute(&self.pool) .await?; Ok(result.last_insert_rowid()) }
async fn mark_done(&self, id: i64) -> Result<bool, sqlx::Error> { let result = sqlx::query("UPDATE tasks SET done = TRUE WHERE id = ?") .bind(id) .execute(&self.pool) .await?; Ok(result.rows_affected() == 1) }
async fn list_open(&self) -> Result<Vec<Task>, sqlx::Error> { sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = FALSE ORDER BY id") .fetch_all(&self.pool) .await }}
#[tokio::main]async fn main() -> Result<(), sqlx::Error> { let pool = SqlitePool::connect("sqlite::memory:").await?; sqlx::query("CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE)") .execute(&pool) .await?;
let repo = SqliteTaskRepo { pool }; repo.add("Buy milk").await?; repo.add("Learn sqlx").await?; repo.add("Walk the dog").await?;
println!("mark 1 done: {}", repo.mark_done(1).await?); println!("mark 99 done: {}", repo.mark_done(99).await?); for task in repo.list_open().await? { println!("[{}] {}: {}", if task.done { "x" } else { " " }, task.id, task.title); } Ok(())}mark 1 done: truemark 99 done: false[ ] 2: Learn sqlx[ ] 3: Walk the dogBentuknya sama dengan versi di memori, hanya saja async dan mengembalikan Result, karena database sungguhan bisa gagal (disk penuh, jaringan putus). Langkah berikutnya: ganti Vec di proyek web API-mu dengan repository ini.
Ringkasan
Section titled “Ringkasan”- rusqlite untuk SQLite sync yang sederhana, sqlx untuk SQL biasa yang async (pilihan pertama yang paling umum), Diesel untuk query builder yang dicek saat compile, SeaORM untuk ORM async seperti Entity Framework.
- Repository menyembunyikan tempat data disimpan di balik beberapa method. Mulai dengan
HashMap, lalu ganti dengan database nanti. - Di sqlx:
query+.bind()+.execute()untuk perubahan,query_as+fetch_all/fetch_one/fetch_optionaluntuk membaca baris ke struct#[derive(FromRow)]. - Simpan connection string di
DATABASE_URL, bukan di kode. SQLite memakai placeholder?, Postgres memakai$1,$2. - Ubah tabel dengan migration (
sqlx migrate add/run), jangan pernah dengan tangan. - Selalu bind input user. Jangan pernah menempelkannya ke SQL dengan
format!. Buat satu pool saat program mulai dan bagikan.
Kuis singkat
Section titled “Kuis singkat”Kuis singkat
1. Kamu ingin menulis SQL biasa, memakai async, dan punya pilihan query yang dicek saat compile. Crate mana yang paling cocok?
2. Method sqlx mana yang sebaiknya kamu pakai kalau sebuah query bisa mengembalikan nol atau satu baris?
3. Kenapa format!("... WHERE name = '{name}'") berbahaya?
4. Apa yang dilakukan connection pool?
Skor: 0 / 4
Bacaan lebih lanjut
Section titled “Bacaan lebih lanjut”- sqlx di docs.rs (bahasa Inggris): referensi API, dengan
query,query_as, makroquery!, pool, dan semua database yang didukung. - sqlx-cli di docs.rs (bahasa Inggris): cara memasang CLI, membuat database, menjalankan dan membatalkan migration, serta memakai mode offline untuk makro
query!. - Getting Started with Diesel (bahasa Inggris): panduan resmi langkah demi langkah, dari
diesel setupsampai menambah, mengubah, dan menghapus baris. - Dokumentasi SeaORM (bahasa Inggris): entity, relasi, migration, dan generator kode
sea-orm-cli. - OWASP SQL Injection Prevention Cheat Sheet (bahasa Inggris): cara kerja SQL injection dan bagaimana parameterized query mencegahnya, untuk bahasa apa pun.