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:
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 gabunganMasalahnya 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:
== 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: 3Mari kita telaah pesan penting pada output di atas:
- Input
Budimenghasilkan 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. - Input
' OR '1'='1merusak struktur SQL: tanda kutip menutup literal nama, lalu potonganOR '1'='1menjadi kondisi SQL yang selalu benar. HasilSELECT * FROM pengguna WHERE nama = '' OR '1'='1'akan mengembalikan seluruh baris tabel — data orang lain ikut terbaca. - 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 kondisiWHEREtetap membandingkan nama dengan string yang utuh. setInt(2, 10)dirender tanpa tanda kutip: setter yang berbeda menandakan tipe data yang berbeda — nilaiintbukan teks, tidak perlu pembatas, dan tidak mungkin membuka literal apa pun.setString(3, ...)ditolak: template hanya memiliki dua parameter, dan indeks valid adalah 1 sampai 2. Error semacam ini padaPreparedStatementasli muncul sebagaiSQLExceptionsaat eksekusi — biasanya berisi kata parameter index.
Setter Sesuai Tipe dan Bentuk Aslinya #
PreparedStatement menyediakan setter untuk hampir semua tipe SQL. Yang paling sering terpakai:
| Setter | Tipe SQL yang lazim | Catatan |
|---|---|---|
setString(i, v) | VARCHAR, TEXT | paling umum |
setInt(i, v) / setLong(i, v) | INTEGER, BIGINT | |
setDouble(i, v) | DOUBLE | untuk uang lebih baik setBigDecimal |
setBigDecimal(i, v) | DECIMAL | presisi tetap, tepat untuk nilai uang |
setDate(i, v) / setTimestamp(i, v) | DATE / TIMESTAMP | memakai java.sql.Date |
setBoolean(i, v) | BOOLEAN | sebagian driver memetakan ke angka |
setObject(i, v) | tipe apa pun | driver 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:
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 berkaspublic 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();
}
}