PostgreSQL

PostgreSQL #

PostgreSQL adalah database pilihan utama komunitas Rust. Jika MySQL cocok untuk aplikasi web umum dan Oracle untuk enterprise legacy, PostgreSQL memberikan fitur terlengkap dengan lisensi open-source: tipe data yang kaya (UUID, array, JSONB, enum), full-text search bawaan, dan kemampuan seperti LISTEN/NOTIFY untuk real-time event. sqlx mendukung PostgreSQL dengan sangat baik — termasuk tipe-tipe eksklusif PostgreSQL seperti UUID, array TEXT[], dan JSONB. Artikel ini membahas seluruh stack PostgreSQL dari Rust: koneksi, query, tipe data unik, transaksi, dan pola-pola yang tidak ada di MySQL atau MSSQL.

Instalasi #

[dependencies]
sqlx = { version = "0.7", features = [
    "runtime-tokio-native-tls",
    "postgres",      # driver PostgreSQL
    "macros",        # query! dan query_as! macro
    "chrono",        # DateTime, NaiveDateTime
    "uuid",          # UUID type
    "json",          # serde_json::Value untuk JSONB
] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }

Install sqlx-cli untuk migrasi:

cargo install sqlx-cli --no-default-features --features native-tls,postgres

Koneksi dan Connection Pool #

use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;

async fn buat_pool(database_url: &str) -> Result<PgPool, sqlx::Error> {
    PgPoolOptions::new()
        .max_connections(20)
        .min_connections(2)
        .acquire_timeout(std::time::Duration::from_secs(5))
        .idle_timeout(std::time::Duration::from_secs(300))
        .max_lifetime(std::time::Duration::from_secs(1800))
        .connect(database_url)
        .await
}

#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
    // Format: postgres://user:password@host:port/database
    let url = std::env::var("DATABASE_URL")
        .unwrap_or_else(|_| "postgres://postgres:password@localhost/contoh".to_string());

    let pool = buat_pool(&url).await?;

    // Verifikasi dan cek versi
    let versi: String = sqlx::query_scalar("SELECT version()")
        .fetch_one(&pool)
        .await?;
    println!("PostgreSQL: {}", &versi[..40]);

    // Jalankan migrasi saat startup
    sqlx::migrate!("./migrations").run(&pool).await?;

    Ok(())
}

Membuat Tabel #

-- migrations/001_buat_tabel.sql

-- Ekstensi untuk UUID (tersedia di semua PostgreSQL modern)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE pengguna (
    id          BIGSERIAL       PRIMARY KEY,
    uuid        UUID            NOT NULL DEFAULT gen_random_uuid(),
    nama        TEXT            NOT NULL,
    email       TEXT            NOT NULL UNIQUE,
    password    TEXT            NOT NULL,
    peran       TEXT            NOT NULL DEFAULT 'user'
                                CHECK (peran IN ('user', 'admin', 'moderator')),
    aktif       BOOLEAN         NOT NULL DEFAULT TRUE,
    tag         TEXT[]          NOT NULL DEFAULT '{}',  -- array of text
    metadata    JSONB,                                   -- JSON yang bisa diquery
    dibuat_pada TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    diperbarui  TIMESTAMPTZ     NOT NULL DEFAULT NOW()
);

CREATE TABLE artikel (
    id              BIGSERIAL       PRIMARY KEY,
    pengguna_id     BIGINT          NOT NULL REFERENCES pengguna(id) ON DELETE CASCADE,
    judul           TEXT            NOT NULL,
    slug            TEXT            NOT NULL UNIQUE,
    konten          TEXT            NOT NULL,
    -- Full-text search vector
    tsv             TSVECTOR        GENERATED ALWAYS AS (
                        to_tsvector('indonesian', judul || ' ' || konten)
                    ) STORED,
    diterbitkan     BOOLEAN         NOT NULL DEFAULT FALSE,
    dibuat_pada     TIMESTAMPTZ     NOT NULL DEFAULT NOW()
);

-- Index untuk performa
CREATE INDEX idx_pengguna_email ON pengguna(email);
CREATE INDEX idx_artikel_pengguna ON artikel(pengguna_id);
CREATE INDEX idx_artikel_diterbitkan ON artikel(diterbitkan, dibuat_pada DESC);
-- GIN index untuk full-text search
CREATE INDEX idx_artikel_tsv ON artikel USING GIN(tsv);
-- GIN index untuk JSONB queries
CREATE INDEX idx_pengguna_metadata ON pengguna USING GIN(metadata);

-- Trigger untuk update otomatis kolom diperbarui
CREATE OR REPLACE FUNCTION update_diperbarui()
RETURNS TRIGGER AS $$
BEGIN
    NEW.diperbarui = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_pengguna_diperbarui
    BEFORE UPDATE ON pengguna
    FOR EACH ROW EXECUTE FUNCTION update_diperbarui();

Struct dengan Tipe Eksklusif PostgreSQL #

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;

#[derive(Debug, FromRow, Serialize, Deserialize, Clone)]
struct Pengguna {
    pub id: i64,                        // BIGSERIAL → i64
    pub uuid: Uuid,                     // UUID → uuid::Uuid
    pub nama: String,                   // TEXT → String
    pub email: String,
    #[serde(skip_serializing)]
    pub password: String,
    pub peran: String,
    pub aktif: bool,                    // BOOLEAN → bool (native, tidak perlu konversi)
    pub tag: Vec<String>,               // TEXT[] → Vec<String>
    pub metadata: Option<serde_json::Value>, // JSONB → Option<serde_json::Value>
    pub dibuat_pada: DateTime<Utc>,     // TIMESTAMPTZ → DateTime<Utc>
    pub diperbarui: DateTime<Utc>,
}

#[derive(Debug, FromRow, Serialize)]
struct Artikel {
    pub id: i64,
    pub pengguna_id: i64,
    pub judul: String,
    pub slug: String,
    pub konten: String,
    pub diterbitkan: bool,
    pub dibuat_pada: DateTime<Utc>,
}
Tipe PostgreSQLTipe Rust
SMALLINTi16
INTEGERi32
BIGINT / BIGSERIALi64
REALf32
DOUBLE PRECISIONf64
BOOLEANbool
TEXT / VARCHARString
BYTEAVec<u8>
UUIDuuid::Uuid
TIMESTAMPTZchrono::DateTime<Utc>
TIMESTAMPchrono::NaiveDateTime
DATEchrono::NaiveDate
TEXT[] / INT[]Vec<String> / Vec<i32>
JSONB / JSONserde_json::Value
NUMERIC / DECIMALrust_decimal::Decimal

Query dan Fetch #

PostgreSQL menggunakan $1, $2 sebagai parameter binding — berbeda dari ? (MySQL) dan :nama (Oracle). sqlx menangani ini secara otomatis.

use sqlx::PgPool;

async fn ambil_semua_pengguna(pool: &PgPool) -> Result<Vec<Pengguna>, sqlx::Error> {
    sqlx::query_as!(
        Pengguna,
        r#"SELECT id, uuid, nama, email, password, peran, aktif,
                  tag, metadata, dibuat_pada, diperbarui
           FROM pengguna
           ORDER BY dibuat_pada DESC"#
    )
    .fetch_all(pool)
    .await
}

async fn cari_pengguna_by_uuid(
    pool: &PgPool,
    uuid: Uuid,
) -> Result<Option<Pengguna>, sqlx::Error> {
    sqlx::query_as!(
        Pengguna,
        r#"SELECT id, uuid, nama, email, password, peran, aktif,
                  tag, metadata, dibuat_pada, diperbarui
           FROM pengguna
           WHERE uuid = $1"#,
        uuid  // $1 secara otomatis
    )
    .fetch_optional(pool)
    .await
}

// Query dengan array parameter — filter berdasarkan peran yang ada di list
async fn pengguna_dengan_peran(
    pool: &PgPool,
    peran_list: &[String],
) -> Result<Vec<Pengguna>, sqlx::Error> {
    sqlx::query_as!(
        Pengguna,
        r#"SELECT id, uuid, nama, email, password, peran, aktif,
                  tag, metadata, dibuat_pada, diperbarui
           FROM pengguna
           WHERE peran = ANY($1)"#,
        peran_list as &[String]  // array sebagai parameter
    )
    .fetch_all(pool)
    .await
}

INSERT dengan RETURNING #

PostgreSQL RETURNING jauh lebih fleksibel dari MySQL — bisa mengembalikan seluruh baris, bukan hanya ID:

async fn buat_pengguna(
    pool: &PgPool,
    nama: &str,
    email: &str,
    password_hash: &str,
) -> Result<Pengguna, sqlx::Error> {
    // RETURNING * — kembalikan seluruh baris yang baru diinsert
    sqlx::query_as!(
        Pengguna,
        r#"INSERT INTO pengguna (nama, email, password)
           VALUES ($1, $2, $3)
           RETURNING id, uuid, nama, email, password, peran, aktif,
                     tag, metadata, dibuat_pada, diperbarui"#,
        nama,
        email,
        password_hash
    )
    .fetch_one(pool)
    .await
}

// INSERT banyak baris sekaligus dengan UNNEST
async fn buat_banyak_pengguna(
    pool: &PgPool,
    nama_list: &[String],
    email_list: &[String],
) -> Result<Vec<i64>, sqlx::Error> {
    // UNNEST mengubah array menjadi baris
    let ids: Vec<i64> = sqlx::query_scalar!(
        r#"INSERT INTO pengguna (nama, email, password)
           SELECT nama, email, 'temp_password'
           FROM UNNEST($1::TEXT[], $2::TEXT[]) AS t(nama, email)
           RETURNING id"#,
        nama_list as &[String],
        email_list as &[String]
    )
    .fetch_all(pool)
    .await?;

    Ok(ids)
}

Update, Upsert, dan DELETE #

async fn perbarui_pengguna(
    pool: &PgPool,
    id: i64,
    nama: &str,
    tag_baru: &[String],
) -> Result<Option<Pengguna>, sqlx::Error> {
    // RETURNING setelah UPDATE — tidak perlu SELECT terpisah
    sqlx::query_as!(
        Pengguna,
        r#"UPDATE pengguna
           SET nama = $1, tag = $2
           WHERE id = $3
           RETURNING id, uuid, nama, email, password, peran, aktif,
                     tag, metadata, dibuat_pada, diperbarui"#,
        nama,
        tag_baru as &[String],
        id
    )
    .fetch_optional(pool)
    .await
}

// INSERT OR UPDATE (UPSERT) dengan ON CONFLICT
async fn upsert_pengguna(
    pool: &PgPool,
    nama: &str,
    email: &str,
    password_hash: &str,
) -> Result<Pengguna, sqlx::Error> {
    sqlx::query_as!(
        Pengguna,
        r#"INSERT INTO pengguna (nama, email, password)
           VALUES ($1, $2, $3)
           ON CONFLICT (email) DO UPDATE
               SET nama = EXCLUDED.nama,
                   diperbarui = NOW()
           RETURNING id, uuid, nama, email, password, peran, aktif,
                     tag, metadata, dibuat_pada, diperbarui"#,
        nama, email, password_hash
    )
    .fetch_one(pool)
    .await
}

Transaksi dengan SAVEPOINT #

PostgreSQL mendukung SAVEPOINT — titik simpan dalam transaksi yang bisa di-rollback sebagian:

use sqlx::{PgPool, Postgres, Transaction};

async fn proses_batch(
    pool: &PgPool,
    items: &[(&str, &str)],
) -> Result<Vec<Result<i64, String>>, sqlx::Error> {
    let mut tx = pool.begin().await?;
    let mut hasil = Vec::new();

    for (nama, email) in items {
        // Buat savepoint sebelum setiap item
        sqlx::query("SAVEPOINT sp_item")
            .execute(&mut *tx)
            .await?;

        match sqlx::query_scalar!(
            "INSERT INTO pengguna (nama, email, password)
             VALUES ($1, $2, 'temp')
             RETURNING id",
            nama, email
        )
        .fetch_one(&mut *tx)
        .await
        {
            Ok(id) => {
                // Berhasil — lepas savepoint
                sqlx::query("RELEASE SAVEPOINT sp_item")
                    .execute(&mut *tx)
                    .await?;
                hasil.push(Ok(id));
            }
            Err(e) => {
                // Gagal — rollback ke savepoint (bukan rollback seluruh transaksi)
                sqlx::query("ROLLBACK TO SAVEPOINT sp_item")
                    .execute(&mut *tx)
                    .await?;
                hasil.push(Err(e.to_string()));
            }
        }
    }

    tx.commit().await?;
    Ok(hasil)
}

Tipe Data Eksklusif PostgreSQL #

Array #

async fn contoh_array(pool: &PgPool) -> Result<(), sqlx::Error> {
    // Insert dengan array
    sqlx::query!(
        "UPDATE pengguna SET tag = $1 WHERE id = $2",
        &["rust", "backend", "api"] as &[&str],
        1i64
    )
    .execute(pool)
    .await?;

    // Query dengan operator array @> (mengandung)
    let pengguna_rust: Vec<String> = sqlx::query_scalar!(
        "SELECT nama FROM pengguna WHERE tag @> ARRAY['rust']"
    )
    .fetch_all(pool)
    .await?;
    println!("Pengguna dengan tag 'rust': {:?}", pengguna_rust);

    // Query dengan ANY
    let aktif_tertentu: Vec<i64> = sqlx::query_scalar!(
        "SELECT id FROM pengguna WHERE $1 = ANY(tag)",
        "admin"
    )
    .fetch_all(pool)
    .await?;

    Ok(())
}

JSONB #

PostgreSQL JSONB bisa diquery langsung dari SQL:

async fn contoh_jsonb(pool: &PgPool) -> Result<(), sqlx::Error> {
    // Insert metadata JSONB
    let metadata = serde_json::json!({
        "preferensi": {"tema": "gelap", "bahasa": "id"},
        "profil": {"kota": "Jakarta", "pekerjaan": "Developer"},
        "login_terakhir": "2024-08-24T10:30:00Z"
    });

    sqlx::query!(
        "UPDATE pengguna SET metadata = $1 WHERE id = $2",
        metadata,
        1i64
    )
    .execute(pool)
    .await?;

    // Query field spesifik dalam JSONB dengan operator ->
    let tema: Option<serde_json::Value> = sqlx::query_scalar!(
        r#"SELECT metadata->'preferensi'->>'tema' FROM pengguna WHERE id = $1"#,
        1i64
    )
    .fetch_optional(pool)
    .await?
    .flatten();
    println!("Tema: {:?}", tema);

    // Filter berdasarkan nilai dalam JSONB dengan @>
    let dev_jakarta: Vec<String> = sqlx::query_scalar!(
        r#"SELECT nama FROM pengguna
           WHERE metadata @> '{"profil": {"kota": "Jakarta"}}'::jsonb"#
    )
    .fetch_all(pool)
    .await?;
    println!("Developer Jakarta: {:?}", dev_jakarta);

    Ok(())
}
async fn cari_artikel_fts(
    pool: &PgPool,
    query: &str,
) -> Result<Vec<Artikel>, sqlx::Error> {
    // websearch_to_tsquery: sintaks yang lebih user-friendly (supports "", -, OR)
    sqlx::query_as!(
        Artikel,
        r#"SELECT id, pengguna_id, judul, slug, konten, diterbitkan, dibuat_pada
           FROM artikel
           WHERE tsv @@ websearch_to_tsquery('indonesian', $1)
             AND diterbitkan = TRUE
           ORDER BY ts_rank(tsv, websearch_to_tsquery('indonesian', $1)) DESC
           LIMIT 20"#,
        query
    )
    .fetch_all(pool)
    .await
}

// Highlight kata yang cocok (snippet)
async fn cari_dengan_highlight(
    pool: &PgPool,
    query: &str,
) -> Result<Vec<(String, String)>, sqlx::Error> {
    let rows = sqlx::query!(
        r#"SELECT
            judul,
            ts_headline(
                'indonesian',
                konten,
                websearch_to_tsquery('indonesian', $1),
                'StartSel=<b>, StopSel=</b>, MaxWords=50, MinWords=25'
            ) AS snippet
           FROM artikel
           WHERE tsv @@ websearch_to_tsquery('indonesian', $1)
             AND diterbitkan = TRUE
           ORDER BY ts_rank(tsv, websearch_to_tsquery('indonesian', $1)) DESC
           LIMIT 10"#,
        query
    )
    .fetch_all(pool)
    .await?;

    Ok(rows.into_iter().map(|r| (r.judul, r.snippet.unwrap_or_default())).collect())
}

LISTEN/NOTIFY — Real-time Event #

PostgreSQL punya mekanisme pub/sub bawaan yang berguna untuk real-time notification antar proses atau koneksi:

use sqlx::postgres::PgListener;

// Listener — menerima notifikasi
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
    let url = "postgres://postgres:password@localhost/contoh";
    let pool = PgPoolOptions::new().max_connections(5).connect(url).await?;

    // Listener untuk menerima notifikasi
    let mut listener = PgListener::connect_with(&pool).await?;
    listener.listen_all(vec!["pesanan_baru", "pembayaran_berhasil"]).await?;

    println!("Mendengarkan notifikasi...");

    // Spawn task terpisah untuk mengirim notifikasi
    let pool_kirim = pool.clone();
    tokio::spawn(async move {
        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
        sqlx::query("SELECT pg_notify('pesanan_baru', $1)")
            .bind(r#"{"id": 1001, "total": 150000}"#)
            .execute(&pool_kirim)
            .await
            .unwrap();
        println!("Notifikasi dikirim");
    });

    // Terima notifikasi
    while let Ok(notif) = listener.recv().await {
        println!(
            "Notifikasi di channel '{}': {}",
            notif.channel(),
            notif.payload()
        );

        // Parse payload JSON
        if let Ok(data) = serde_json::from_str::<serde_json::Value>(notif.payload()) {
            println!("Data: {:?}", data);
        }

        break; // untuk demo — biasanya loop selamanya
    }

    Ok(())
}

Perbedaan dari MySQL dan MSSQL #

MySQL/MSSQL                          PostgreSQL

Parameter binding
  ? (MySQL)                           $1, $2, $3, ...
  @P1 (MSSQL)

RETURNING setelah INSERT
  LAST_INSERT_ID() (MySQL)            RETURNING id  (atau RETURNING *)
  OUTPUT INSERTED.id (MSSQL)

Auto-increment
  AUTO_INCREMENT (MySQL)              BIGSERIAL  atau  GENERATED ALWAYS AS IDENTITY
  IDENTITY(1,1) (MSSQL)

String type
  VARCHAR, TEXT (MySQL)               TEXT  (tidak ada limit, atau VARCHAR(n))
  NVARCHAR (MSSQL)

Boolean
  TINYINT(1) (MySQL)                  BOOLEAN  (tipe native)
  BIT (MSSQL)

UUID
  VARCHAR(36) (manual)                UUID  (tipe native, compact 16 byte)

JSON
  JSON (tidak bisa diindex) (MySQL)   JSONB  (bisa diindex dengan GIN)
  (tidak ada JSON type) (MSSQL)

Array
  Tidak ada tipe array                TEXT[], INT[], BOOLEAN[], dll.

Full-text search
  FULLTEXT index (MySQL)              TSVECTOR + GIN index (lebih powerful)

Case sensitivity string
  Tidak sensitive (MySQL)             Sensitive (gunakan ILIKE untuk case-insensitive)

Upsert
  INSERT ... ON DUPLICATE KEY UPDATE  INSERT ... ON CONFLICT DO UPDATE

Schema
  Database = Schema (MySQL)           Schema terpisah dalam database

Ringkasan #

  • sqlx + PostgreSQL adalah kombinasi terbaik di Rust — async native, compile-time query check, dukungan tipe PostgreSQL yang lengkap termasuk UUID, array, JSONB.
  • Parameter binding $1, $2 — PostgreSQL menggunakan numbered parameter, berbeda dari ? (MySQL). sqlx menangani ini otomatis dari argumen yang diberikan.
  • RETURNING * setelah INSERT/UPDATE — PostgreSQL bisa mengembalikan seluruh baris yang baru diinsert atau diupdate, sehingga tidak perlu SELECT terpisah.
  • TIMESTAMPTZ untuk timestamp yang timezone-aware — selalu gunakan TIMESTAMPTZ (bukan TIMESTAMP) agar tidak ada ambiguitas zona waktu di lingkungan multi-server.
  • BOOLEAN native — tidak perlu konversi manual dari NUMBER(1) atau BIT seperti di Oracle/MSSQL. bool Rust langsung dipetakan.
  • JSONB untuk data semi-terstruktur — bisa diquery dengan operator ->, ->>, @>, dan diindex dengan GIN. Lebih powerful dari JSON di database lain.
  • TEXT[] untuk array — langsung dipetakan ke Vec<String>. Gunakan @> untuk “mengandung”, ANY() untuk “salah satunya cocok”.
  • Full-text search bawaanTSVECTOR + GIN index + websearch_to_tsquery memberikan FTS yang sangat capable tanpa dependensi eksternal.
  • LISTEN/NOTIFY — pub/sub real-time bawaan PostgreSQL, berguna untuk event notification antar service atau invalidasi cache tanpa polling.
  • ON CONFLICT DO UPDATE untuk upsert — jauh lebih elegan dari MySQL ON DUPLICATE KEY UPDATE karena bisa menggunakan EXCLUDED untuk referensi nilai yang ingin diinsert.

← Sebelumnya: Oracle   Berikutnya: MongoDB →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact