Database Relasional
Skala
Industri 🗄️
Aplikasi di dunia kerja nyata jarang sekali yang hanya memakai 1 tabel sederhana. Melanjutkan project Cafe kita, mari merakit Sistem Manajemen Cafe yang menggunakan relasi antar-tabel (Foreign Key & JOIN) dengan SQLite Murni dan Room Database!
1. SQLite Murni vs Room Database
Sistem Android sudah dilengkapi mesin database bawaan bernama SQLite. Di zaman dulu,
developer merakit tabel dan menarik datanya secara manual (SQLite Murni). Namun, karena sangat
rawan error, Google merilis Room—sebuah "Pembungkus Cerdas" (ORM) yang membuat
penulisan SQLite jadi super aman dan rapi.
Sebagai Developer profesional, kamu wajib menguasai keduanya. Menguasai SQLite Murni
memberimu pondasi logika SQL yang kuat, sedangkan menguasai Room membuatmu siap terjun ke dunia
industri modern yang menuntut kode ringkas dan bebas bug.
Sang Pondasi: SQLite Murni
- Bawaan Android (Tanpa perlu download library).
- Kontrol penuh 100% terhadap query relasi tabel secara langsung.
- Sangat rawan Typo: Jika salah ketik perintah
INNER JOIN, aplikasi baru akan Crash saat di-Run! - Kode
Cursorsangat panjang saat membongkar hasil Join dari 2 tabel.
Standar Modern: Room DB
- Verifikasi Aman: Relasi tabel divalidasi sebelum aplikasi dijalankan. Mencegah error fatal.
- Data JOIN otomatis dikemas menjadi satu Object Java (POJO) yang rapi.
- Aturan sangat ketat (Memaksa penggunaan Background Thread).
- Butuh pembuatan banyak class tambahan untuk merepresentasikan relasi.
2. Anatomi Komponen & Konsep POJO
Komponen SQLite Murni
Dalam SQLite Murni, semua logika biasanya ditumpuk dalam 1 file "Pawang" database.
1. SQLiteOpenHelper
Class induk yang wajib diwarisi
(extends). Bertugas membuat file database pertama kali
(onCreate) dan meng-update versinya (onUpgrade).
2. ContentValues
Keranjang belanja khusus tempat menaruh pasangan "Nama Kolom & Nilai" saat akan melakukan operasi Insert atau Update.
3. Cursor
Telunjuk/penunjuk yang bergerak
membaca baris demi baris data hasil SELECT. Membacanya harus
di-looping (while) secara manual.
Tiga Pilar Room Database
Room memakai teknik ORM (Object-Relational Mapping) yang memecah file agar arsitektur lebih bersih.
1. @Entity (Model Tabel)
Class Java biasa yang ditambahkan
anotasi @Entity. Room akan otomatis menyulap variabel di Class ini
menjadi nama Kolom Tabel Database.
2. @Dao (Data Access Object)
File berbentuk Interface. Ini
adalah "Buku Menu Perintah" yang mendaftarkan operasi SQL (seperti
@Insert, @Delete, atau @Query).
3. @Database (Sang Jenderal)
Class abstrak pusat komando yang
menyatukan Entity dan DAO, lalu memerintahkan Android untuk membuat file fisik
berekstensi .db di HP.
Konsep Emas: Apa itu POJO & ORM?
Dalam dunia Android modern, kalian akan sering mendengar kata POJO dan ORM. Apa sih bedanya dengan query murni?
- ORM (Object-Relational Mapping): Ini adalah teknik ajaib milik Room. Di SQLite biasa, hasil database berbentuk "tabel mentah" (Cursor) yang harus kamu tunjuk dan bongkar satu-persatu. Dengan ORM, tabel mentah itu otomatis disulap menjadi Class Object di Java!
- POJO (Plain Old Java Object): POJO adalah wadah/keranjang penampungnya. Dinamakan
Plain (polos) karena dia adalah class Java yang sangat sederhana. Ia tidak
mewarisi fitur rumit Android (seperti
extends Activity). Isinya murni hanya variabel data (sepertiint id,String nama) untuk menampung hasil mapping dari database sebelum dikirim ke Adapter RecyclerView.
3. Komparasi Sintaks SQL (Head-to-Head)
Mari kita lihat mengapa developer profesional lebih menyukai Room. Di bawah ini adalah perbandingan penulisan kode untuk operasi CRUD (Create, Read, Update, Delete) serta operasi database lanjutan. Perhatikan bagaimana POJO mempermudah eksekusi SELECT JOIN!
SQLite Murni
onCreate(). Rawan typo koma
dan spasi!
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL("CREATE TABLE tabel_kategori (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"nama_kategori TEXT)");
}
Room (Entity)
@Entity(tableName = "tabel_kategori")
public class Kategori {
@PrimaryKey(autoGenerate = true)
public int id;
@ColumnInfo(name = "nama_kategori")
public String namaKategori;
}
SQLite Murni
ContentValues untuk merangkai pasangan
"Kolom-Nilai" sebelum di-insert.
public void insertKategori(String nama) {
SQLiteDatabase db = this.getWritableDatabase();
ContentValues cv = new ContentValues();
cv.put("nama_kategori", nama);
db.insert("tabel_kategori", null, cv);
db.close();
}
Room (DAO)
@Dao menggunakan anotasi
@Insert. Room yang akan membuat kodenya.
@Dao
public interface AppDao {
@Insert
void insertKategori(Kategori kategori);
}
SQLite Murni
// UPDATE
ContentValues cv = new ContentValues();
cv.put("nama_kategori", namaBaru);
db.update("tabel_kategori", cv, "id=?", new String[]{String.valueOf(id)});
// DELETE
db.delete("tabel_kategori", "id=?", new String[]{String.valueOf(id)});
Room (DAO)
:
untuk mengambil parameter fungsi.
@Dao
public interface AppDao {
@Update
void updateKategori(Kategori kategori);
// Delete Spesifik pakai Query
@Query("DELETE FROM tabel_kategori WHERE id = :idDipilih")
void deleteById(int idDipilih);
}
SQLite Murni
rawQuery adalah tabel mentah.
Kita harus menggerakkan telunjuk (Cursor) satu per satu dan mengambil data
berdasarkan urutan kolom manual getInt(0), getString(1),
dsb. Jika urutan kolom di SQL terbalik, aplikasi akan kacau!
public List<MenuDetail> getAllDataJoin() {
List<MenuDetail> list = new ArrayList<>();
String sql = "SELECT m.id, m.nama_menu, k.nama_kategori " +
"FROM tabel_menu m INNER JOIN tabel_kategori k " +
"ON m.kat_id = k.id";
Cursor c = db.rawQuery(sql, null);
if (c.moveToFirst()) {
do {
MenuDetail md = new MenuDetail();
md.id = c.getInt(0); // Kolom ke-0 (m.id)
md.namaMenu = c.getString(1); // Kolom ke-1 (m.nama_menu)
md.namaKategori = c.getString(2); // Kolom ke-2 (k.nama_kategori)
list.add(md);
} while (c.moveToNext());
}
c.close();
return list;
}
Room (Dao + POJO)
SANGAT MUDAHMenuDetail) yang memiliki
nama @ColumnInfo sama. Tidak perlu lagi repot melooping Cursor!
// 1. Siapkan Wadah POJO-nya
public class MenuDetail {
public int id;
@ColumnInfo(name = "nama_menu") public String namaMenu;
@ColumnInfo(name = "nama_kategori") public String namaKategori;
}
// 2. Tinggal panggil di DAO. Selesai! Cuma 3 Baris!
@Dao
public interface AppDao {
@Query("SELECT m.id, m.nama_menu, k.nama_kategori " +
"FROM tabel_menu m INNER JOIN tabel_kategori k ON m.kat_id = k.id")
List<MenuDetail> getAllDataJoin();
}
SQLite (Migrasi / onUpgrade)
version, lalu mengeksekusi DROP dan memanggil onCreate
lagi.
@Override
public void onUpgrade(SQLiteDatabase db, int oldVer, int newVer) {
// Cara kasar: Hapus tabel lama, bikin baru.
// (Semua data user akan hilang!)
db.execSQL("DROP TABLE IF EXISTS tabel_kategori");
onCreate(db);
// Cara aman: Tambah kolom baru tanpa hapus tabel
if(oldVer < 2) {
db.execSQL("ALTER TABLE tabel_kategori ADD COLUMN status TEXT");
}
}
Room (Migrations)
Migration dan ubah versi di anotasi
@Database(version = 2).
// Definisikan aturan migrasi
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
@Override
public void migrate(SupportSQLiteDatabase db) {
db.execSQL("ALTER TABLE tabel_kategori ADD COLUMN status TEXT");
}
};
// Panggil saat Builder
Room.databaseBuilder(context, AppDatabase.class, "db")
.addMigrations(MIGRATION_1_2)
.build();
4. Simulator Manajemen Cafe
Ubah toggle "Mesin Database". Coba tambahkan menu baru. Perhatikan bagaimana kode SQL dieksekusi memanjang ke bawah dengan rapi, dan lihat status Thread yang merespon perbedaan antara SQLite Murni dan Room!
Admin Cafe
Inventaris Menu
UI Thread (Main)
Pekerjaan di jalur utama (Rawan Lag).
| id | nama_kategori |
|---|---|
| 1 | Kopi |
| 2 | Non-Kopi |
| 3 | Cemilan |
| id | nama_menu | harga | stok | kat_id (FK) | Aksi |
|---|
5. Bedah Kode Lengkap: Form s/d Adapter
Pilih dan pelajari susunan lengkap dari aplikasi berikut! Kode di bawah mencakup UI (XML), Model, Logic Database, sampai ke RecyclerView Adapter!
Info File: activity_main.xml & item_menu.xml
Desain Antarmuka (User Interface).
activity_main.xml sebagai form input dan wadah utama.
item_menu.xml sebagai "Cetakan" baris data yang akan
digandakan.
File desain ini 100% SAMA dan akan digunakan baik untuk versi SQLite Murni maupun Room Database nanti.
<!-- 1. activity_main.xml (Layar Utama) -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:padding="16dp">
<EditText android:id="@+id/etNama" android:hint="Nama Menu"
android:layout_width="match_parent" android:layout_height="wrap_content"/>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content">
<EditText android:id="@+id/etHarga" android:hint="Harga"
android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:inputType="number"/>
<EditText android:id="@+id/etStok" android:hint="Stok"
android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:inputType="number"/>
</LinearLayout>
<!-- Data list dari Spinner ini akan kita set dari File Java biar aman! -->
<Spinner android:id="@+id/spKategori"
android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginVertical="8dp"/>
<Button android:id="@+id/btnTambah" android:text="Simpan Menu"
android:layout_width="match_parent" android:layout_height="wrap_content"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMenu" android:layout_marginTop="16dp"
android:layout_width="match_parent" android:layout_height="match_parent"/>
</LinearLayout>
<!-- ======================================================== -->
<!-- 2. item_menu.xml (Desain Baris List) -->
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_margin="8dp" app:cardCornerRadius="12dp" app:cardElevation="4dp">
<LinearLayout android:orientation="horizontal" android:padding="16dp"
android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center_vertical">
<LinearLayout android:orientation="vertical" android:layout_weight="1"
android:layout_width="0dp" android:layout_height="wrap_content">
<TextView android:id="@+id/tvJudul" android:textStyle="bold" android:textSize="16sp"
android:layout_width="wrap_content" android:layout_height="wrap_content"/>
<TextView android:id="@+id/tvKategori" android:textSize="12sp"
android:layout_width="wrap_content" android:layout_height="wrap_content"/>
<TextView android:id="@+id/tvHargaStok" android:textColor="#10b981" android:textStyle="bold"
android:layout_width="wrap_content" android:layout_height="wrap_content"/>
</LinearLayout>
<!-- Tombol Delete -->
<ImageButton android:id="@+id/btnDelete"
android:layout_width="40dp" android:layout_height="40dp"
android:src="@android:drawable/ic_menu_delete"
android:background="?attr/selectableItemBackgroundBorderless"/>
</LinearLayout>
</androidx.cardview.widget.CardView>
Info File: MenuDetail.java (Model Data)
Sebagai Wadah Penampung Baris Data.
Perintah SQL JOIN dari Cursor menghasilkan data mentah. Kita butuh class model ini untuk menampung output tiap barisnya menjadi objek agar rapi saat dikirim ke Adapter.
Berbeda dengan Room yang melakukan mapping otomatis, di SQLite kita harus membedah dan memasukkan data ke class ini secara manual menggunakan Cursor.
package com.example.sqliteapp;
public class MenuDetail {
public int id;
public String namaMenu;
public int harga;
public int stok;
public String namaKategori; // Hasil relasi JOIN dari tabel_kategori
// Kosongkan Constructor jika kamu mengisi datanya satu-satu manual via obj.id = ... di Helper
public MenuDetail() {
}
}
Info File: DatabaseHelper.java
Pawang Database (SQLiteOpenHelper).
Mengeksekusi raw query SQL untuk membuat tabel, mengikat
FOREIGN KEY, dan memproses operasi CRUD manual.
Sangat
rawan typo! Pastikan spasi saat merangkai teks SQL JOIN (di method
getAllMenu...) diketik dengan benar.
package com.example.sqliteapp;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import java.util.ArrayList;
import java.util.List;
public class DatabaseHelper extends SQLiteOpenHelper {
public DatabaseHelper(Context context) {
super(context, "kedai.db", null, 1);
}
@Override
public void onCreate(SQLiteDatabase db) {
// 1. Buat Tabel Induk (Kategori)
db.execSQL("CREATE TABLE tabel_kategori (id INTEGER PRIMARY KEY, nama_kategori TEXT)");
// 2. Buat Tabel Anak (Menu) DENGAN FOREIGN KEY
db.execSQL("CREATE TABLE tabel_menu (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"nama_menu TEXT, " +
"harga INTEGER, " +
"stok INTEGER, " +
"kategori_id INTEGER, " +
"FOREIGN KEY(kategori_id) REFERENCES tabel_kategori(id))");
// Masukkan Data Awal Kategori
db.execSQL("INSERT INTO tabel_kategori VALUES (1, 'Kopi'), (2, 'Non-Kopi'), (3, 'Cemilan')");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
db.execSQL("DROP TABLE IF EXISTS tabel_menu");
db.execSQL("DROP TABLE IF EXISTS tabel_kategori");
onCreate(db);
}
// Fungsi INSERT Menu
public void insertMenu(String nama, int harga, int stok, int kategoriId) {
SQLiteDatabase db = this.getWritableDatabase();
ContentValues cv = new ContentValues();
cv.put("nama_menu", nama);
cv.put("harga", harga);
cv.put("stok", stok);
cv.put("kategori_id", kategoriId);
db.insert("tabel_menu", null, cv);
db.close();
}
// Fungsi HAPUS Data
public void deleteMenu(int id) {
SQLiteDatabase db = this.getWritableDatabase();
db.delete("tabel_menu", "id=?", new String[]{String.valueOf(id)});
db.close();
}
// Fungsi SELECT DENGAN INNER JOIN (Menggabungkan 2 Tabel ke wujud MenuDetail)
public List<MenuDetail> getAllMenuWithKategori() {
List<MenuDetail> list = new ArrayList<>();
SQLiteDatabase db = this.getReadableDatabase();
// Query JOIN: Ambil data menu dan nama kategorinya
String query = "SELECT m.id, m.nama_menu, m.harga, m.stok, k.nama_kategori " +
"FROM tabel_menu m " +
"INNER JOIN tabel_kategori k ON m.kategori_id = k.id " +
"ORDER BY m.id DESC";
Cursor cursor = db.rawQuery(query, null);
if (cursor.moveToFirst()) {
do {
MenuDetail md = new MenuDetail();
md.id = cursor.getInt(0);
md.namaMenu = cursor.getString(1);
md.harga = cursor.getInt(2);
md.stok = cursor.getInt(3);
md.namaKategori = cursor.getString(4); // Hasil dari JOIN
list.add(md);
} while (cursor.moveToNext());
}
cursor.close();
db.close();
return list;
}
}
Info File: MenuAdapter.java
Mesin Perakitan RecyclerView.
Menggandakan desain XML, menempelkan teks dari `MenuDetail`, dan menyetel aksi klik pada tombol Hapus.
Karena SQLite murni mengizinkan akses dari UI Thread, operasi hapus data bisa dipanggil secara langsung menggunakan `dbHelper.deleteMenu(...)`.
package com.example.sqliteapp;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.List;
public class MenuAdapter extends RecyclerView.Adapter<MenuAdapter.MenuViewHolder> {
private List<MenuDetail> listMenu;
private DatabaseHelper dbHelper; // Akses DB langsung
public MenuAdapter(List<MenuDetail> listMenu, DatabaseHelper dbHelper) {
this.listMenu = listMenu;
this.dbHelper = dbHelper;
}
// Fungsi penyalur data dinamis jika ada update baru
public void setData(List<MenuDetail> dataBaru) {
this.listMenu = dataBaru;
notifyDataSetChanged();
}
@NonNull
@Override
public MenuViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_menu, parent, false);
return new MenuViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull MenuViewHolder holder, int position) {
MenuDetail menu = listMenu.get(position);
holder.tvJudul.setText(menu.namaMenu);
holder.tvKategori.setText("Kategori: " + menu.namaKategori);
holder.tvHargaStok.setText("Rp " + menu.harga + " | Stok: " + menu.stok);
// LOGIKA HAPUS DATA LANGSUNG (Karena SQLite Murni, dibolehkan walau tak 100% ideal)
holder.btnDelete.setOnClickListener(v -> {
// 1. Hapus dari Database Fisik
dbHelper.deleteMenu(menu.id);
// 2. Hapus dari memori List
listMenu.remove(position);
// 3. Buat animasi mengerut di layar UI
notifyItemRemoved(position);
notifyItemRangeChanged(position, listMenu.size());
Toast.makeText(holder.itemView.getContext(), "Menu dihapus!", Toast.LENGTH_SHORT).show();
});
}
@Override
public int getItemCount() { return listMenu.size(); }
public class MenuViewHolder extends RecyclerView.ViewHolder {
TextView tvJudul, tvKategori, tvHargaStok;
ImageButton btnDelete;
public MenuViewHolder(@NonNull View itemView) {
super(itemView);
tvJudul = itemView.findViewById(R.id.tvJudul);
tvKategori = itemView.findViewById(R.id.tvKategori);
tvHargaStok = itemView.findViewById(R.id.tvHargaStok);
btnDelete = itemView.findViewById(R.id.btnDelete);
}
}
}
Info File: MainActivity.java
Pusat Eksekusi (Layar Utama).
Menangkap input dari user, melempar data baru ke Database Helper, dan merefresh daftar menu di layar.
Memanggil
fungsi getAllMenuWithKategori secara langsung setiap kali ada
perubahan untuk meng-update Adapter.
package com.example.sqliteapp;
import android.os.Bundle;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Spinner;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
public class MainActivity extends AppCompatActivity {
private DatabaseHelper dbHelper;
private MenuAdapter adapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// Deklarasi UI
EditText etNama = findViewById(R.id.etNama);
EditText etHarga = findViewById(R.id.etHarga);
EditText etStok = findViewById(R.id.etStok);
Spinner spKategori = findViewById(R.id.spKategori);
Button btnTambah = findViewById(R.id.btnTambah);
RecyclerView rvMenu = findViewById(R.id.rvMenu);
// Mengisi data ke dalam Spinner secara aman
String[] kategoriArr = {"Kopi", "Non-Kopi", "Cemilan"};
ArrayAdapter<String> spinAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, kategoriArr);
spKategori.setAdapter(spinAdapter);
// 1. Siapkan Database Helper & Adapter
dbHelper = new DatabaseHelper(this);
adapter = new MenuAdapter(dbHelper.getAllMenuWithKategori(), dbHelper);
rvMenu.setLayoutManager(new LinearLayoutManager(this));
rvMenu.setAdapter(adapter);
// 2. Saat Tombol Klik Ditekan
btnTambah.setOnClickListener(v -> {
String nama = etNama.getText().toString();
if(nama.isEmpty()) return;
int harga = Integer.parseInt(etHarga.getText().toString());
int stok = Integer.parseInt(etStok.getText().toString());
// +1 karena indeks spinner dimulai dari 0, sedangkan ID Kategori kita mulai dari 1
int katId = spKategori.getSelectedItemPosition() + 1;
// Simpan ke DB Murni (Berjalan di Main Thread)
dbHelper.insertMenu(nama, harga, stok, katId);
// Refresh Data ke Adapter
adapter.setData(dbHelper.getAllMenuWithKategori());
// Kosongkan Form
etNama.setText("");
etHarga.setText("");
etStok.setText("");
});
}
}
Pasang Library Room
Tambahkan kode ini ke dalam file
build.gradle.kts (Module :app) di bagian dependencies, lalu klik
tombol Sync Now di pojok kanan atas Android Studio.
// Di dalam file build.gradle.kts (Module :app)
// Temukan blok dependencies { ... } lalu tambahkan:
def room_version = "2.6.1"
implementation("androidx.room:room-runtime:$room_version")
annotationProcessor("androidx.room:room-compiler:$room_version")
Info File: Kategori.java (Tabel Induk)
Membangun struktur Tabel Induk.
Class
dengan anotasi @Entity akan otomatis disulap jadi tabel fisik
oleh Room saat aplikasi di-install.
@PrimaryKey harus ditambahkan agar setiap data punya ID unik.
package com.example.cafeapp;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
@Entity(tableName = "tabel_kategori")
public class Kategori {
@PrimaryKey(autoGenerate = true)
public int id;
@ColumnInfo(name = "nama_kategori")
public String namaKategori;
public Kategori(String namaKategori) {
this.namaKategori = namaKategori;
}
}
Info File: MenuDetail.java (POJO)
Sebagai Keranjang Penampung (Plain Old Java Object).
Perintah SQL JOIN akan menghasilkan gabungan 2 tabel. Kita butuh class netral ini untuk menampung output-nya secara rapi.
POJO
singkatan dari Plain Old Java Object. Ini adalah class biasa tanpa
aturan/fungsi rumit dari Android (seperti extends). Murni hanya
buat nampung variabel data.
package com.example.cafeapp;
import androidx.room.ColumnInfo;
// BUKAN Entity/Tabel! Hanya class biasa.
public class MenuDetail {
public int id;
@ColumnInfo(name = "nama_menu")
public String namaMenu;
public int harga;
public int stok;
// Kolom ini berasal dari tabel Kategori lho!
@ColumnInfo(name = "nama_kategori")
public String namaKategori;
}
Info File: CafeDao.java (@Dao)
Katalog Perintah SQL (Data Access Object).
File
Interface ini mendaftarkan fungsi-fungsi manipulasi data. Cukup beri anotasi
seperti @Insert atau @Query, dan Room akan
otomatis menyulapnya menjadi kode SQL beneran di belakang layar!
Lihat
betapa saktinya @Query JOIN di sini. Room langsung otomatis
memasukkan hasilnya ke dalam class POJO MenuDetail. Tanpa
Cursor sama sekali!
package com.example.cafeapp;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.Query;
import java.util.List;
@Dao
public interface CafeDao {
@Insert
void insertKategori(Kategori kategori);
@Insert
void insertMenu(MenuCafe menu);
// Tarik List Data Join (Otomatis mengubahnya menjadi List Object!)
@Query("SELECT m.id, m.nama_menu, m.harga, m.stok, k.nama_kategori " +
"FROM tabel_menu m INNER JOIN tabel_kategori k ON m.kategori_id = k.id " +
"ORDER BY m.id DESC")
List<MenuDetail> getMenuLengkap();
// Hapus Berdasarkan ID yang dipilih
@Query("DELETE FROM tabel_menu WHERE id = :menuId")
void deleteMenuById(int menuId);
}
Info File: AppDatabase.java
Sang Pusat Komando DB.
Menyatukan
semua Entity dan Dao, lalu menciptakan file fisik cafe_room.db
di HP menggunakan sistem Singleton.
Kita
menyisipkan addCallback agar tabel kategori terisi otomatis
("Kopi", "Cemilan") saat aplikasi pertama kali di-install.
package com.example.cafeapp;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.room.Database;
import androidx.room.Room;
import androidx.room.RoomDatabase;
import androidx.sqlite.db.SupportSQLiteDatabase;
import java.util.concurrent.Executors;
// Daftarkan KEDUA tabel di sini!
@Database(entities = {Kategori.class, MenuCafe.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {
public abstract CafeDao cafeDao();
private static volatile AppDatabase INSTANCE;
public static AppDatabase getInstance(Context context) {
if (INSTANCE == null) {
synchronized (AppDatabase.class) {
if (INSTANCE == null) {
INSTANCE = Room.databaseBuilder(
context.getApplicationContext(), AppDatabase.class, "cafe_room.db")
// Memasukkan data dummy awal Kategori saat Database baru lahir
.addCallback(roomCallback)
.build();
}
}
}
return INSTANCE;
}
private static RoomDatabase.Callback roomCallback = new RoomDatabase.Callback() {
@Override
public void onCreate(@NonNull SupportSQLiteDatabase db) {
super.onCreate(db);
// Pekerja khusus di awal instalasi aplikasi
Executors.newSingleThreadExecutor().execute(() -> {
CafeDao dao = INSTANCE.cafeDao();
dao.insertKategori(new Kategori("Kopi"));
dao.insertKategori(new Kategori("Non-Kopi"));
dao.insertKategori(new Kategori("Cemilan"));
});
}
};
}
Info File: MenuAdapter.java
Mesin Perakitan UI.
Sama seperti SQLite, dia menggandakan cetakan dan menempelkan teks. Namun, Logika Tombol Hapus-nya berbeda!
🚨 Room
HARAM merusak/menahan UI Thread! Kita wajib meminta bantuan
ExecutorService (Pegawai Gudang) dan Handler
(Walkie-Talkie) untuk kembali ke jalur Kasir (UI).
package com.example.cafeapp;
import android.os.Handler;
import android.os.Looper;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.List;
import java.util.concurrent.Executors;
public class MenuAdapter extends RecyclerView.Adapter<MenuAdapter.MenuViewHolder> {
private List<MenuDetail> listMenu;
private AppDatabase db; // Butuh akses ke DB Induk Room
public MenuAdapter(List<MenuDetail> listMenu, AppDatabase db) {
this.listMenu = listMenu;
this.db = db;
}
public void setData(List<MenuDetail> dataBaru) {
this.listMenu = dataBaru;
notifyDataSetChanged();
}
@NonNull
@Override
public MenuViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_menu, parent, false);
return new MenuViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull MenuViewHolder holder, int position) {
MenuDetail menu = listMenu.get(position);
holder.tvJudul.setText(menu.namaMenu);
holder.tvKategori.setText("Kategori: " + menu.namaKategori);
holder.tvHargaStok.setText("Rp " + menu.harga + " | Stok: " + menu.stok);
// LOGIKA HAPUS ROOM (WAJIB BACKGROUND THREAD!)
holder.btnDelete.setOnClickListener(v -> {
// 1. Kirim instruksi hapus ke Background
Executors.newSingleThreadExecutor().execute(() -> {
db.cafeDao().deleteMenuById(menu.id); // Hapus di Database
listMenu.remove(position); // Hapus di List Memori Lokal
// 2. Pakai Handler untuk kembali melapor (menelpon) ke UI Thread (Main)
new Handler(Looper.getMainLooper()).post(() -> {
// Animasi Mengerut (Hanya boleh dipanggil di UI Thread!)
notifyItemRemoved(position);
notifyItemRangeChanged(position, listMenu.size());
Toast.makeText(holder.itemView.getContext(), "Dihapus secara pro!", Toast.LENGTH_SHORT).show();
});
});
});
}
@Override
public int getItemCount() { return listMenu.size(); }
public class MenuViewHolder extends RecyclerView.ViewHolder {
TextView tvJudul, tvKategori, tvHargaStok;
ImageButton btnDelete;
public MenuViewHolder(@NonNull View itemView) {
super(itemView);
tvJudul = itemView.findViewById(R.id.tvJudul);
tvKategori = itemView.findViewById(R.id.tvKategori);
tvHargaStok = itemView.findViewById(R.id.tvHargaStok);
btnDelete = itemView.findViewById(R.id.btnDelete);
}
}
}
Info File: MainActivity.java
Pusat Eksekusi (Sang Kasir).
Menangkap
input, lalu mendelegasikan tugas berat (menyimpan ke Room) kepada Pekerja
Gudang executor.
Setelah
data disave di Background, kita memanggil runOnUiThread agar
dapat kembali ke jalur Kasir (UI) untuk memperbarui layar RecyclerView.
package com.example.cafeapp;
import android.os.Bundle;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Spinner;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class MainActivity extends AppCompatActivity {
private AppDatabase db;
private MenuAdapter adapter;
private ExecutorService executor = Executors.newSingleThreadExecutor();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
EditText etNama = findViewById(R.id.etNama);
EditText etHarga = findViewById(R.id.etHarga);
EditText etStok = findViewById(R.id.etStok);
Spinner spKategori = findViewById(R.id.spKategori);
Button btnTambah = findViewById(R.id.btnTambah);
RecyclerView rvMenu = findViewById(R.id.rvMenu);
// Mengisi data ke dalam Spinner secara aman
String[] kategoriArr = {"Kopi", "Non-Kopi", "Cemilan"};
ArrayAdapter<String> spinAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, kategoriArr);
spKategori.setAdapter(spinAdapter);
// 1. Siapkan DB Induk dan Pasang Adapter Kosong dulu
db = AppDatabase.getInstance(this);
adapter = new MenuAdapter(new ArrayList<>(), db);
rvMenu.setLayoutManager(new LinearLayoutManager(this));
rvMenu.setAdapter(adapter);
// Refresh List saat layar dibuka
refreshDaftarMenu();
// 2. Saat Tombol Klik Ditekan (INSERT)
btnTambah.setOnClickListener(v -> {
String nama = etNama.getText().toString();
if(nama.isEmpty()) return;
int harga = Integer.parseInt(etHarga.getText().toString());
int stok = Integer.parseInt(etStok.getText().toString());
int katId = spKategori.getSelectedItemPosition() + 1;
// WAJIB JALAN DI BACKGROUND!
executor.execute(() -> {
// Simpan ke Database
db.cafeDao().insertMenu(new MenuCafe(nama, harga, stok, katId));
// Ambil data terbarunya & Refresh UI lewat RunOnUiThread
// Harus dipastikan narik data SETELAH insert selesai.
List<MenuDetail> dataBaru = db.cafeDao().getMenuLengkap();
runOnUiThread(() -> {
adapter.setData(dataBaru);
etNama.setText("");
etHarga.setText("");
etStok.setText("");
});
});
});
}
private void refreshDaftarMenu() {
executor.execute(() -> {
List<MenuDetail> list = db.cafeDao().getMenuLengkap();
runOnUiThread(() -> adapter.setData(list));
});
}
}
5. Mengintip Isi Database
Karena Room adalah "Pembungkus" dari SQLite, file database yang dihasilkan tetaplah file
SQLite biasa (.db) dan tersimpan di lokasi yang sama persis:
/data/data/com.package.aplikasi/databases/.
Lalu bagaimana cara kita
mengecek apakah data kita benar-benar masuk ke tabel?
| id (PK) | nama_menu | harga | stok | kategori_id |
|---|---|---|---|---|
| 1 | Espresso | 20000 | 50 | 1 |
| 2 | Matcha Latte | 25000 | 30 | 2 |
1. Database Inspector (Modern)
Ini cara paling ajaib! Saat aplikasi berjalan (Run) di Emulator/HP, klik tab App Inspection di panel bawah Android Studio.
- Kamu bisa melihat tabel dan isi data secara Real-time.
- Bisa memodifikasi data langsung dari Android Studio.
- Bisa mengetik perintah SQL manual untuk testing.
2. Export ke PC (Cara Lama)
Jika kamu ingin
mengambil filenya: Buka Device File Explorer ➔ cari path
/data/data/<package_name>/databases/.
🚨 PENTING: Karena Room menggunakan sistem Write-Ahead Logging (WAL), kamu WAJIB
mendownload 3 file sekaligus (.db, .db-wal, dan
.db-shm) agar isinya tidak kosong saat dibuka di aplikasi DB Browser for
SQLite!
6. Tugas Super: Kasir Cafe!
Kalian sudah menguasai Relasi Database (Modul 9) dan RecyclerView (Modul 8). Buktikan keahlianmu dengan merakit sistem pencatat pesanan kasir cafe utuh yang fungsional!
🗡️ Misi A: Veteran (Gunakan SQLite Murni)
- Buat Tabel
KategoriPesanan(Id, Tipe: "Dine-in / Takeaway"). - Buat Tabel
Pesanan(Id, Nama Pelanggan, Total Harga,kategori_idFK). - Buat fungsi `INNER JOIN` di DB Helper untuk menarik riwayat pesanan beserta tipe pesanannya.
- Di `MainActivity`, buat desain Form Input dengan Spinner, simpan data, lalu tampilkan rapi di RecyclerView!
🚀 Misi B: Modern (Gunakan Room Database)
- Bangun 2 Pilar Entity (
KategoriPesanan.javadanPesanan.java) yang diikat anotasi `@ForeignKey`. - Isi kategori default (Dine-in & Takeaway) lewat `addCallback` di AppDatabase.
- Tulis `@Query("SELECT ... INNER JOIN ...")` di DAO yang mengembalikan POJO
PesananDetail. - Di `MainActivity`, gunakan `ExecutorService` untuk Insert dan memuat data, lalu lemparkan ke Adapter RecyclerView menggunakan `runOnUiThread`!
Kunci Helper Kasir (SQLite Murni)
public void insertPesanan(String namaPelanggan, int total, int katId) {
SQLiteDatabase db = this.getWritableDatabase();
ContentValues cv = new ContentValues();
cv.put("nama_pelanggan", namaPelanggan);
cv.put("total_harga", total);
cv.put("kategori_id", katId);
db.insert("tabel_pesanan", null, cv);
db.close();
}
public List<PesananDetail> getAllPesanan() {
List<PesananDetail> list = new ArrayList<>();
SQLiteDatabase db = this.getReadableDatabase();
// INNER JOIN Kategori
String sql = "SELECT p.id, p.nama_pelanggan, p.total_harga, k.tipe_pesanan " +
"FROM tabel_pesanan p " +
"INNER JOIN tabel_kategori_pesanan k ON p.kategori_id = k.id " +
"ORDER BY p.id DESC";
Cursor c = db.rawQuery(sql, null);
if (c.moveToFirst()) {
do {
PesananDetail detail = new PesananDetail();
detail.id = c.getInt(0);
detail.namaPelanggan = c.getString(1);
detail.totalHarga = c.getInt(2);
detail.tipePesanan = c.getString(3); // Misal: "Takeaway"
list.add(detail);
} while (c.moveToNext());
}
return list;
}
Kunci Executor Main Activity (Room DB)
// Di dalam MainActivity saat Tombol "Simpan Pesanan" diklik:
btnTambah.setOnClickListener(v -> {
String nama = etNamaPelanggan.getText().toString();
int total = Integer.parseInt(etTotal.getText().toString());
int katId = spTipe.getSelectedItemPosition() + 1; // Asumsi spinner mulai dari id 1
// WAJIB Suruh Pegawai Gudang menyimpan data
executor.execute(() -> {
db.kasirDao().insertPesanan(new Pesanan(nama, total, katId));
// Tarik ulang datanya di background
List<PesananDetail> dataBaru = db.kasirDao().getRiwayatPesanan();
// Lapor ke Kasir UI untuk update RecyclerView
runOnUiThread(() -> {
adapter.setData(dataBaru); // Method khusus di dalam Adapter-mu
adapter.notifyDataSetChanged();
etNamaPelanggan.setText("");
});
});
});