Hampir semua SQL yang dikirim aplikasi memuat nilai dari pengguna: nama yang dicari, harga yang difilter, nomor halaman. Cara paling naif adalah menempelkan nilai itu langsung ke string SQL — dan cara itulah akar dari SQL injection, celah keamanan yang bertahan puluhan tahun dalam daftar risiko OWASP. JDBC memberikan jawaban struktural: PreparedStatement, statement yang SQL-nya berisi placeholder tanda tanya dan nilai diikat terpisah lewat setter. Chapter ini membedah mekanisme pre-compilation di baliknya, aturan indeks berbasis satu, dan kontras visual antara SQL yang digabung versus SQL yang diikat.

Masalah Penggabungan Teks #

Pola berikut terlihat tidak berbahaya padahal berbahaya:

java Pola yang salah: nilai pengguna digabung ke SQL
String nama = inputDariPengguna;                 // bisa apa pun, termasuk tanda kutip
String sql = "SELECT * FROM pengguna WHERE nama = '" + nama + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);           // database membaca hasil gabungan

Masalahnya murni soal bahasa: dalam SQL, tanda kutip tunggal adalah pembatas literal teks. Ketika pengguna mengirim nilai yang mengandung tanda kutip, nilai tersebut tidak lagi dibaca sebagai data, melainkan sebagai potongan kode SQL. Database tidak bisa membedakan bagian yang ditulis programmer dari bagian yang diketik pengguna — semuanya sudah menjadi satu string yang sama. Kita akan lihat efek persisnya pada demo di bawah.

Pre-compilation dan Parameter Terikat #

PreparedStatement memisahkan struktur dari data sejak awal. Aplikasi mengirim template berisi tanda tanya — SELECT * FROM pengguna WHERE nama = ? — lalu database mem-parse dan menyusun rencana eksekusi template tersebut satu kali (pre-compilation). Baru setelah itu nilai dikirim melalui protokol sebagai data terpisah, per parameter, lengkap dengan tipenya. Karena nilai tidak pernah melewati tahap parsing SQL, tanda kutip dalam nilai tidak punya kekuatan apa pun — ia hanyalah karakter di dalam data.

Aturan mainnya dua dan sederhana. Pertama, indeks parameter berbasis 1: tanda tanya pertama adalah parameter 1, kedua adalah parameter 2 — berbeda dengan kebiasaan indeks array Java yang mulai dari 0. Kedua, setter dipilih sesuai tipe: setString(1, "Budi") untuk teks, setInt(2, 10) untuk bilangan, dan rekan-rekannya untuk tipe lain. Keuntungan sampingan: database dapat menggunakan ulang rencana eksekusi yang sama untuk pemanggilan berulang — satu penyebab utama PreparedStatement juga lebih cepat untuk SQL yang dieksekusi berkali-kali.

Kontras Rawan versus Aman #

Program berikut memperlakukan dua pola itu sebagai manipulasi string murni sehingga bisa dijalankan tanpa database. Kelas KueriBerparameter meniru perilaku PreparedStatement: template disimpan apa adanya, nilai diikat pada indeksnya, dan method render() hanya ilustrasi bagaimana nilai diperlakukan sebagai data — perlu ditegaskan, driver sungguhan tidak menggabungkan nilai ke string SQL sama sekali:

public class KontrasParameter {

    public static void main(String[] args) {
        System.out.println("== Pola rawan: penggabungan teks ==");
        tampilRawan("Budi");
        tampilRawan("' OR '1'='1");

        System.out.println();
        System.out.println("== Pola aman: placeholder ? + parameter terikat ==");
        KueriBerparameter aman = new KueriBerparameter(
                "SELECT * FROM pengguna WHERE nama = ?");
        aman.setString(1, "' OR '1'='1");
        System.out.println("template         : " + aman.templateDasar());
        System.out.println("parameter 1      : " + aman.nilaiParameter(1)
                + " (dikirim terpisah, bukan digabung)");
        System.out.println("ilustrasi render : " + aman.render());

        System.out.println();
        System.out.println("== Tipe parameter berbeda, setter berbeda ==");
        KueriBerparameter halaman = new KueriBerparameter(
                "SELECT * FROM pengguna WHERE kota = ? LIMIT ?");
        halaman.setString(1, "Jakarta");
        halaman.setInt(2, 10);
        System.out.println("ilustrasi render : " + halaman.render());

        System.out.println();
        System.out.println("== Indeks parameter berbasis 1 ==");
        try {
            halaman.setString(3, "Solo");
        } catch (IllegalArgumentException e) {
            System.out.println("DITOLAK: " + e.getMessage());
        }
    }

    /** Pola yang HARUS dihindari: nilai pengguna ditempel langsung ke SQL. */
    static void tampilRawan(String input) {
        String sql = "SELECT * FROM pengguna WHERE nama = '" + input + "'";
        System.out.println("input : " + input);
        System.out.println("SQL   : " + sql);
        if (input.contains("'")) {
            System.out.println("catatan: tanda kutip menutup literal SQL,"
                    + " kondisi WHERE bisa diubah pengguna");
        }
        System.out.println();
    }
}

/** Meniru perilaku PreparedStatement: template tetap, nilai terikat terpisah. */
class KueriBerparameter {

    private final String template;
    private final String[] nilai;
    private final String[] tipe;

    KueriBerparameter(String template) {
        int jumlah = template.length() - template.replace("?", "").length();
        this.template = template;
        this.nilai = new String[jumlah];
        this.tipe = new String[jumlah];
    }

    void setString(int indeks, String v) {
        simpan(indeks, v, "String");
    }

    void setInt(int indeks, int v) {
        simpan(indeks, String.valueOf(v), "int");
    }

    private void simpan(int indeks, String v, String jenis) {
        if (indeks < 1 || indeks > nilai.length) {
            throw new IllegalArgumentException(
                    "indeks parameter harus 1.." + nilai.length
                            + ", diterima: " + indeks);
        }
        nilai[indeks - 1] = v;
        tipe[indeks - 1] = jenis;
    }

    String templateDasar() {
        return template;
    }

    String nilaiParameter(int indeks) {
        return nilai[indeks - 1];
    }

    /** Ilustrasi saja: driver sungguhan mengirim nilai terpisah dari template. */
    String render() {
        StringBuilder sql = new StringBuilder();
        int param = 0;
        for (char c : template.toCharArray()) {
            if (c == '?') {
                param++;
                if (nilai[param - 1] == null) {
                    throw new IllegalStateException(
                            "parameter " + param + " belum diikat");
                }
                if ("int".equals(tipe[param - 1])) {
                    sql.append(nilai[param - 1]);
                } else {
                    sql.append('\'')
                            .append(nilai[param - 1].replace("'", "''"))
                            .append('\'');
                }
            } else {
                sql.append(c);
            }
        }
        return sql.toString();
    }
}

Output yang Dihasilkan:

text
== Pola rawan: penggabungan teks ==
input : Budi
SQL   : SELECT * FROM pengguna WHERE nama = 'Budi'

input : ' OR '1'='1
SQL   : SELECT * FROM pengguna WHERE nama = '' OR '1'='1'
catatan: tanda kutip menutup literal SQL, kondisi WHERE bisa diubah pengguna


== Pola aman: placeholder ? + parameter terikat ==
template         : SELECT * FROM pengguna WHERE nama = ?
parameter 1      : ' OR '1'='1 (dikirim terpisah, bukan digabung)
ilustrasi render : SELECT * FROM pengguna WHERE nama = ''' OR ''1''=''1'

== Tipe parameter berbeda, setter berbeda ==
ilustrasi render : SELECT * FROM pengguna WHERE kota = 'Jakarta' LIMIT 10

== Indeks parameter berbasis 1 ==
DITOLAK: indeks parameter harus 1..2, diterima: 3

Mari kita telaah pesan penting pada output di atas:

  1. Input Budi menghasilkan SQL yang tampak wajar: penggabungan teks memang bekerja untuk nilai “sopan”. Bahayanya justru karena pola ini lulus uji dengan data normal, programmer merasa aman — sampai pengguna lain mengirim nilai lain.
  2. Input ' OR '1'='1 merusak struktur SQL: tanda kutip menutup literal nama, lalu potongan OR '1'='1 menjadi kondisi SQL yang selalu benar. Hasil SELECT * FROM pengguna WHERE nama = '' OR '1'='1' akan mengembalikan seluruh baris tabel — data orang lain ikut terbaca.
  3. Versi berparameter tetap selektif: template tidak berubah, nilai dikirim terpisah, dan pada ilustrasi render seluruh input dibaca sebagai satu nilai teks — tanda kutip di dalamnya di-escape menjadi dua tanda kutip ('') sesuai aturan literal SQL, sehingga kondisi WHERE tetap membandingkan nama dengan string yang utuh.
  4. setInt(2, 10) dirender tanpa tanda kutip: setter yang berbeda menandakan tipe data yang berbeda — nilai int bukan teks, tidak perlu pembatas, dan tidak mungkin membuka literal apa pun.
  5. setString(3, ...) ditolak: template hanya memiliki dua parameter, dan indeks valid adalah 1 sampai 2. Error semacam ini pada PreparedStatement asli muncul sebagai SQLException saat eksekusi — biasanya berisi kata parameter index.

Setter Sesuai Tipe dan Bentuk Aslinya #

PreparedStatement menyediakan setter untuk hampir semua tipe SQL. Yang paling sering terpakai:

SetterTipe SQL yang lazimCatatan
setString(i, v)VARCHAR, TEXTpaling umum
setInt(i, v) / setLong(i, v)INTEGER, BIGINT
setDouble(i, v)DOUBLEuntuk uang lebih baik setBigDecimal
setBigDecimal(i, v)DECIMALpresisi tetap, tepat untuk nilai uang
setDate(i, v) / setTimestamp(i, v)DATE / TIMESTAMPmemakai java.sql.Date
setBoolean(i, v)BOOLEANsebagian driver memetakan ke angka
setObject(i, v)tipe apa pundriver menebak tipe Java terbaik

Versi lengkapnya dengan database sungguhan tampak seperti berikut — ditampilkan sebagai contoh karena driver SQLite tidak terpasang pada lingkungan uji Course ini:

java PreparedStatement dengan SQLite (butuh driver xerial)
String sql = "INSERT INTO pelanggan (nama, kota, saldo) VALUES (?, ?, ?)";
try (Connection conn = DriverManager.getConnection("jdbc:sqlite:toko.db");
     PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setString(1, "Budi");
    ps.setString(2, "Jakarta");
    ps.setBigDecimal(3, new BigDecimal("150000.00"));
    int baris = ps.executeUpdate();
}

Satu fasilitas PreparedStatement masih kita simpan untuk dibahas khusus: addBatch() dan executeBatch(), cara mengirim puluhan parameter sekaligus dalam satu bolak-balik jaringan — pokok bahasan Chapter Batch Processing. Sebelum ke sana, mari kita bedah dulu pembawa hasil executeQuery: ResultSet beserta kursor, getter bertipe, dan trik menangani NULL.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

KontrasParameter.java

Unduh berkas
java
public class KontrasParameter {

    public static void main(String[] args) {
        System.out.println("== Pola rawan: penggabungan teks ==");
        tampilRawan("Budi");
        tampilRawan("' OR '1'='1");

        System.out.println();
        System.out.println("== Pola aman: placeholder ? + parameter terikat ==");
        KueriBerparameter aman = new KueriBerparameter(
                "SELECT * FROM pengguna WHERE nama = ?");
        aman.setString(1, "' OR '1'='1");
        System.out.println("template         : " + aman.templateDasar());
        System.out.println("parameter 1      : " + aman.nilaiParameter(1)
                + " (dikirim terpisah, bukan digabung)");
        System.out.println("ilustrasi render : " + aman.render());

        System.out.println();
        System.out.println("== Tipe parameter berbeda, setter berbeda ==");
        KueriBerparameter halaman = new KueriBerparameter(
                "SELECT * FROM pengguna WHERE kota = ? LIMIT ?");
        halaman.setString(1, "Jakarta");
        halaman.setInt(2, 10);
        System.out.println("ilustrasi render : " + halaman.render());

        System.out.println();
        System.out.println("== Indeks parameter berbasis 1 ==");
        try {
            halaman.setString(3, "Solo");
        } catch (IllegalArgumentException e) {
            System.out.println("DITOLAK: " + e.getMessage());
        }
    }

    /** Pola yang HARUS dihindari: nilai pengguna ditempel langsung ke SQL. */
    static void tampilRawan(String input) {
        String sql = "SELECT * FROM pengguna WHERE nama = '" + input + "'";
        System.out.println("input : " + input);
        System.out.println("SQL   : " + sql);
        if (input.contains("'")) {
            System.out.println("catatan: tanda kutip menutup literal SQL,"
                    + " kondisi WHERE bisa diubah pengguna");
        }
        System.out.println();
    }
}

/** Meniru perilaku PreparedStatement: template tetap, nilai terikat terpisah. */
class KueriBerparameter {

    private final String template;
    private final String[] nilai;
    private final String[] tipe;

    KueriBerparameter(String template) {
        int jumlah = template.length() - template.replace("?", "").length();
        this.template = template;
        this.nilai = new String[jumlah];
        this.tipe = new String[jumlah];
    }

    void setString(int indeks, String v) {
        simpan(indeks, v, "String");
    }

    void setInt(int indeks, int v) {
        simpan(indeks, String.valueOf(v), "int");
    }

    private void simpan(int indeks, String v, String jenis) {
        if (indeks < 1 || indeks > nilai.length) {
            throw new IllegalArgumentException(
                    "indeks parameter harus 1.." + nilai.length
                            + ", diterima: " + indeks);
        }
        nilai[indeks - 1] = v;
        tipe[indeks - 1] = jenis;
    }

    String templateDasar() {
        return template;
    }

    String nilaiParameter(int indeks) {
        return nilai[indeks - 1];
    }

    /** Ilustrasi saja: driver sungguhan mengirim nilai terpisah dari template. */
    String render() {
        StringBuilder sql = new StringBuilder();
        int param = 0;
        for (char c : template.toCharArray()) {
            if (c == '?') {
                param++;
                if (nilai[param - 1] == null) {
                    throw new IllegalStateException(
                            "parameter " + param + " belum diikat");
                }
                if ("int".equals(tipe[param - 1])) {
                    sql.append(nilai[param - 1]);
                } else {
                    sql.append('\'')
                            .append(nilai[param - 1].replace("'", "''"))
                            .append('\'');
                }
            } else {
                sql.append(c);
            }
        }
        return sql.toString();
    }
}