Drift #
Ketika kita membangun aplikasi Flutter yang membutuhkan penyimpanan data transaksional, relasi antar entitas yang mendalam, dan kueri yang sangat spesifik, basis data relasional adalah pilihan terbaik. Di ekosistem perangkat mobile, SQLite merupakan standar de facto untuk basis data relasional. Namun, menulis kueri SQL dalam bentuk teks mentah (raw SQL strings) di Dart sangatlah rentan terhadap kesalahan (error-prone) karena tidak ada pemeriksaan tipe data saat kompilasi. Untuk menjembatani kekuatan SQL penuh dengan kenyamanan kode Dart yang aman (type-safe), kita memiliki Drift.
Drift (yang sebelumnya dikenal dengan nama Moor) adalah pustaka persistensi data reaktif untuk Flutter dan Dart yang dibangun di atas SQLite. Drift memeriksa skema database dan kueri kita pada saat proses kompilasi (compile-time), menghasilkan kelas-kelas Dart penampung data secara otomatis, serta menyediakan sistem aliran data (Stream) yang bereaksi secara instan ketika data di dalam tabel berubah. Di dalam artikel ini, kita akan mengulas tuntas Drift dari instalasi, pemodelan tabel, mekanisme koneksi multi-platform, kueri kompleks (JOIN), hingga strategi migrasi skema yang rumit.
Pendahuluan: Mengapa Memilih Drift? #
Di tengah maraknya basis data NoSQL seperti Hive dan ObjectBox, basis data relasional berbasis SQLite tetap memiliki tempat yang sangat istimewa dalam arsitektur perangkat lunak. Ada beberapa skenario di mana SQLite jauh lebih unggul dibandingkan NoSQL:
- Integritas Relasional (Foreign Keys): Menjamin bahwa data anak tidak dapat menunjuk ke data induk yang tidak ada (misalnya, item pesanan harus selalu merujuk ke ID pesanan yang valid).
- Normalisasi Data: Menghindari duplikasi data dengan membagi informasi ke dalam beberapa tabel yang saling terhubung.
- Kueri Agregasi Kompleks: Melakukan kalkulasi matematika langsung di dalam mesin database, seperti menghitung total pengeluaran per kategori per bulan menggunakan fungsi
GROUP BYdanSUM().
Drift mengambil seluruh kekuatan SQLite tersebut dan membungkusnya ke dalam paradigma berorientasi objek yang aman. Beberapa keunggulan utama Drift meliputi:
- Pemeriksaan Tipe Data Compile-Time: Jika kita salah mengetikkan nama kolom atau tipe data di kueri kita, compiler Dart akan langsung mendeteksinya sebagai error sebelum aplikasi dijalankan.
- Paradigma Reaktif: Drift melacak tabel mana saja yang dibaca oleh kueri aktif. Ketika kita menulis data baru ke suatu tabel, kueri aktif yang membaca tabel tersebut akan otomatis memperbarui aliran datanya (Stream).
- Dukungan Multi-Platform (Termasuk Web): Drift dapat berjalan di Android, iOS, macOS, Windows, Linux, dan Web (melalui teknologi SQLite WASM). Ini adalah keunggulan besar dibandingkan ObjectBox yang tidak mendukung Web.
- Generasi Kode Otomatis: Drift secara otomatis membuat kelas-kelas Dart penampung data (Data Classes) dan kelas pembaruan data (Companion Classes) untuk mempercepat penulisan kode CRUD kita.
Instalasi & Setup Proyek #
Untuk menggunakan Drift, kita membutuhkan dependensi runtime serta beberapa peralatan tambahan pada development dependencies untuk melakukan proses generasi kode binding.
Tambahkan dependensi berikut pada berkas pubspec.yaml kita:
dependencies:
flutter:
sdk: flutter
drift: ^2.23.1
drift_flutter: ^0.2.4 # Helper resmi untuk konfigurasi koneksi di Flutter
dev_dependencies:
drift_dev: ^2.23.1 # Generator kode Drift
build_runner: ^2.4.13 # Generator kode standar Dart
Setelah menambahkan konfigurasi tersebut, jalankan perintah flutter pub get di terminal proyek kita.
Definisi Tabel Relasional & Fluent Column Builder #
Dalam Drift, kita mendefinisikan tabel database sebagai kelas Dart yang mewarisi kelas Table. Kita menggunakan sintaks builder mengalir (fluent builder) untuk menentukan tipe data kolom, panjang karakter, nilai default, dan konstran kunci asing (foreign keys).
Mari kita rancang skema database toko retail sederhana yang terdiri dari tabel Categories (Kategori), Products (Produk), Orders (Pesanan), dan tabel penghubung OrderItems (Item Pesanan):
// lib/core/database/tables.dart
import 'package:drift/drift.dart';
// 1. Tabel Kategori (Categories)
class Categories extends Table {
// Primary key auto-increment
IntColumn get id => integer().autoIncrement()();
// Kolom teks dengan batas panjang karakter minimal 1 dan maksimal 100
TextColumn get name => text().withLength(min: 1, max: 100)();
// Kolom teks opsional (nullable)
TextColumn get description => text().nullable()();
// Nilai default menggunakan fungsi SQL bawaan waktu saat ini
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
// 2. Tabel Produk (Products)
class Products extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text().withLength(min: 1, max: 255)();
// Menyimpan bilangan pecahan desimal untuk harga
RealColumn get price => real()();
// Properti boolean dengan nilai default true
BoolColumn get isAvailable => boolean().withDefault(const Constant(true))();
IntColumn get stock => integer().withDefault(const Constant(0))();
// Kunci Asing (Foreign Key) yang merujuk ke tabel Categories
// references() menjamin integritas relasional data di tingkat SQLite
IntColumn get categoryId => integer().references(Categories, #id)();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
// 3. Tabel Pesanan (Orders)
class Orders extends Table {
IntColumn get id => integer().autoIncrement()();
// Kolom dengan nilai yang dijamin unik di seluruh database
TextColumn get orderNumber => text().unique()();
RealColumn get grandTotal => real()();
TextColumn get status => text().withDefault(const Constant('pending'))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
// 4. Tabel Junction Many-to-Many: Orders ↔ Products
class OrderItems extends Table {
IntColumn get orderId => integer().references(Orders, #id)();
IntColumn get productId => integer().references(Products, #id)();
IntColumn get quantity => integer()();
RealColumn get priceAtSale => real()();
// Menentukan kunci utama gabungan (Composite Primary Key)
@override
Set<Column> get primaryKey => {orderId, productId};
}
[!NOTE] Mengapa ada tanda kurung ganda
()()di akhir kolom? Hal ini sering kali membingungkan bagi pengembang yang baru belajar Drift. Pemanggilan metode pertama sepertitext()mengembalikan objek builder kolom. Metode berantai sepertiwithLength()menambahkan konfigurasi ke builder tersebut. Tanda kurung ganda kosong di akhir()adalah panggilan fungsi Dart untuk mengeksekusi builder tersebut dan mengubahnya menjadi objek properti kolom (Column) yang sebenarnya.
Kelas Database & Koneksi Multi-Platform #
Setelah mendefinisikan tabel-tabel tersebut, kita harus membuat kelas database utama yang mengoordinasikan pembukaan berkas fisik dan inisialisasi skema. Pustaka drift_flutter menyediakan metode driftDatabase() yang secara cerdas mendeteksi platform perangkat dan secara otomatis memilih driver yang paling optimal:
- Mobile & Desktop: Menggunakan SQLite native C library (
sqlite3). - Web: Menggunakan kompilasi WebAssembly (WASM) dari SQLite dengan memanfaatkan penyimpanan virtual indexedDB milik peramban web.
Berikut adalah implementasi kelas database utama:
// lib/core/database/app_database.dart
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'tables.dart';
// Nama berkas generator yang wajib kita sertakan
part 'app_database.g.dart';
@DriftDatabase(tables: [Categories, Products, Orders, OrderItems])
class AppDatabase extends _$AppDatabase {
// Konstruktor membuka koneksi database fisik secara aman
AppDatabase() : super(_openConnection());
// 1. Tentukan nomor versi skema awal
@override
int get schemaVersion => 1;
// Metode internal untuk mengonfigurasi jalur penyimpanan
static QueryExecutor _openConnection() {
// driftDatabase secara otomatis mengatur enkapsulasi storage per platform
return driftDatabase(name: 'toko_database');
}
}
Jalankan generator kode untuk memproses berkas app_database.g.dart:
flutter pub run build_runner build --delete-conflicting-outputs
Siklus Alur Migrasi Skema (Database Migration) #
Saat kita merilis pembaruan aplikasi ke Play Store atau App Store, kita sering kali perlu mengubah struktur tabel kita (misalnya menambahkan kolom baru atau membuat tabel baru). Drift menyediakan kelas MigrationStrategy yang memungkinkan kita untuk mengelola evolusi skema database secara terstruktur tanpa menghapus data yang sudah dimiliki oleh pengguna.
Diagram Proses Migrasi #
Mari kita lihat bagaimana Drift memproses migrasi versi database saat aplikasi pertama kali dinyalakan:
graph TD
Start["Startup Aplikasi (Membuka Database)"] --> Read["Membaca Versi Skema yang Tersimpan di Disk"]
Read --> Compare{"Bandingkan dengan schemaVersion di Kode"}
Compare -->|Sama| Open["Picu beforeOpen (Seed Data / Set PRAGMAs)"]
Compare -->|Kode > Disk| Migrate["Picu onUpgrade (Jalankan Langkah Migrasi)"]
Compare -->|Kode < Disk| Error["Toleransi / Rollback Error"]
Migrate -->|Tambah Kolom / Tabel| Validate["Validasi Struktur Skema Baru"]
Validate --> Open
Open --> Ready["Database Siap Digunakan (Daftar Query Aktif)"]Implementasi Strategi Migrasi Kompleks #
Berikut adalah contoh bagaimana kita menangani migrasi dari versi 1 ke versi 2 (menambahkan kolom baru stock ke tabel Products), dan dari versi 2 ke versi 3 (membuat tabel baru OrderItems):
// Modifikasi di dalam kelas AppDatabase kita
@DriftDatabase(tables: [Categories, Products, Orders, OrderItems])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
// Naikkan versi skema ke angka 3
@override
int get schemaVersion => 3;
@override
MigrationStrategy get migration {
return MigrationStrategy(
// Dipanggil pertama kali saat database dibuat di perangkat kosong
onCreate: (Migrator m) async {
await m.createAll();
},
// Dipanggil saat terdeteksi perbedaan versi skema
onUpgrade: (Migrator m, int from, int to) async {
if (from < 2) {
// Migrasi dari Versi 1 ke Versi 2: Tambahkan kolom stock ke tabel Products
// Kita menggunakan properti 'stock' yang di-generate otomatis di _$AppDatabase
await m.addColumn(products, products.stock);
}
if (from < 3) {
// Migrasi dari Versi 2 ke Versi 3: Buat tabel OrderItems baru
await m.createTable(orderItems);
}
},
// Dipanggil setiap kali database berhasil dibuka
beforeOpen: (OpeningDetails details) async {
// Mengaktifkan fitur Foreign Key Constraints pada SQLite secara eksplisit.
// Secara default, SQLite menonaktifkan Foreign Key demi kompatibilitas mundur.
await customStatement('PRAGMA foreign_keys = ON;');
if (details.wasCreated) {
// Lakukan pengisian data awal (seeding) jika database baru saja dibuat
await into(categories).insert(
CategoriesCompanion.insert(name: 'Kategori Umum'),
);
}
},
);
}
static QueryExecutor _openConnection() {
return driftDatabase(name: 'toko_database');
}
}
Melalui strategi migrasi ini, Drift memastikan bahwa pembaruan data berjalan dengan mulus di perangkat pengguna tanpa mengorbankan konsistensi data yang ada.
Operasi CRUD: Data Class vs Companion Class #
Generasi kode Drift menghasilkan dua kelas berbeda untuk setiap tabel:
- Data Class (misal:
Product): Kelas representasi baris data utuh. Semua propertinya bersifat non-nullable (kecuali kolom tersebut memang dikonfigurasi nullable di tabel). Sangat cocok untuk menampilkan data di UI. - Companion Class (misal:
ProductsCompanion): Kelas khusus untuk operasi insert dan update. Propertinya dibungkus dalam tipeValue<T>. Tipe ini digunakan untuk memberi tahu Drift apakah suatu kolom harus diikutsertakan dalam query update, dibiarkan kosong agar menggunakan default, atau di-set bernilai null secara eksplisit.
Berikut adalah demonstrasi operasi CRUD menggunakan tipe-tipe kelas tersebut:
import 'package:drift/drift.dart';
import 'lib/core/database/app_database.dart'; // Import file database kita
// 1. INSERT DATA
Future<int> insertNewProduct(AppDatabase db, String nama, double harga, int catId) async {
// Companion.insert mewajibkan pengisian semua kolom yang tidak memiliki default / autoIncrement
return await db.into(db.products).insert(
ProductsCompanion.insert(
name: nama,
price: harga,
categoryId: catId,
// Properti stock dan isAvailable opsional karena memiliki default di tabel
),
);
}
// 2. UPSERT DATA (Insert or Update on Conflict)
Future<void> upsertProduct(AppDatabase db, int id, String nama, double harga, int catId) async {
await db.into(db.products).insertOnConflictUpdate(
ProductsCompanion(
id: Value(id), // Jika ID ini sudah ada, Drift akan memperbarui datanya
name: Value(nama),
price: Value(harga),
categoryId: Value(catId),
),
);
}
// 3. READ DATA (Select dengan Filter & Sorting)
Future<List<Product>> getAvailableProducts(AppDatabase db) async {
return await (db.select(db.products)
..where((p) => p.isAvailable.equals(true)) // Filter isAvailable = true
..orderBy([(p) => OrderingTerm.desc(p.price)])) // Urutkan dari termahal
.get();
}
// 4. UPDATE DATA (Pemutakhiran Spesifik)
Future<void> updateProductStock(AppDatabase db, int id, int newStock) async {
// Melakukan update hanya pada kolom stock saja untuk baris data dengan ID tertentu
await (db.update(db.products)..where((p) => p.id.equals(id)))
.write(
ProductsCompanion(
stock: Value(newStock),
),
);
}
// 5. DELETE DATA
Future<void> deleteProductById(AppDatabase db, int id) async {
await (db.delete(db.products)..where((p) => p.id.equals(id))).go();
}
Kueri Reaktif: Memanfaatkan watch() #
Di Drift, kita dapat dengan mudah mengubah kueri pembacaan biasa (get()) menjadi kueri reaktif (watch()). Kueri reaktif akan mengembalikan objek Stream Dart. Setiap kali terjadi modifikasi data (baik melalui operasi insert, update, atau delete) pada tabel-tabel yang dibaca oleh kueri tersebut, Stream akan memancarkan data terbaru secara otomatis.
Berikut adalah contoh implementasi di UI menggunakan StreamBuilder:
// lib/features/inventory/presentation/widgets/product_stream_list.dart
import 'package:flutter/material.dart';
import '../../core/database/app_database.dart';
import '../../../../main.dart'; // Mengakses instance database global
class ProductStreamList extends StatelessWidget {
const ProductStreamList({super.key});
// Kueri reaktif untuk memantau semua produk yang stoknya hampir habis (< 5)
Stream<List<Product>> _watchLowStockProducts() {
return (database.select(database.products)
..where((p) => p.stock.lessThan(5)))
.watch();
}
@override
Widget build(BuildContext context) {
return StreamBuilder<List<Product>>(
stream: _watchLowStockProducts(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
final products = snapshot.data ?? [];
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
title: Text(product.name),
trailing: Text('Sisa Stok: ${product.stock}'),
);
},
);
},
);
}
}
UI kita sekarang sepenuhnya sinkron dengan kondisi database. Jika ada sinkronisasi latar belakang yang memperbarui stok produk dari server API, antarmuka pengguna kita akan terperbarui secara instan tanpa perlu memicu muat ulang halaman secara manual.
Pencarian Multi-Tabel (JOIN) #
Dalam basis data relasional, kita sering kali perlu menampilkan informasi gabungan dari beberapa tabel. Sebagai contoh, kita ingin menampilkan nama produk beserta dengan nama kategori produk tersebut. Drift menyediakan API deklaratif yang mempermudah operasi JOIN.
Mari kita lihat cara membuat kueri gabungan secara aman menggunakan Drift:
// lib/core/database/models/product_with_category.dart
import '../app_database.dart';
// Kelas penampung hasil join data
class ProductWithCategory {
final Product product;
final Category category;
ProductWithCategory({required this.product, required this.category});
}
Berikut adalah kueri untuk melakukan innerJoin antara tabel Products dan Categories:
Future<List<ProductWithCategory>> getProductsWithCategory(AppDatabase db) async {
// 1. Lakukan join tabel induk ke tabel relasi
final query = db.select(db.products).join([
innerJoin(
db.categories,
db.categories.id.equalsExp(db.products.categoryId),
),
]);
// 2. Eksekusi query
final List<TypedResult> rows = await query.get();
// 3. Map baris data mentah SQLite ke dalam objek Dart kustom kita
return rows.map((row) {
return ProductWithCategory(
product: row.readTable(db.products),
category: row.readTable(db.categories),
);
}).toList();
}
Metode row.readTable(...) secara cerdas menguraikan baris kolom hasil join dan menyusunnya kembali menjadi objek data class masing-masing tabel secara otomatis.
Merapikan Kode dengan DAO Pattern #
Jika semua kueri kita ditulis langsung di dalam kelas AppDatabase utama, berkas tersebut akan membengkak dengan cepat dan menjadi sulit untuk dirawat. Drift menyediakan pola DAO (Data Access Object) untuk memisahkan logika kueri berdasarkan domain bisnis tertentu (seperti ProductDao, OrderDao, dsb.).
Berikut adalah cara memisahkan kueri produk ke dalam sebuah DAO:
// lib/core/database/daos/product_dao.dart
import 'package:drift/drift.dart';
import '../app_database.dart';
import '../tables.dart';
// Generasi file pembantu DAO
part 'product_dao.g.dart';
@DriftAccessor(tables: [Products, Categories])
class ProductDao extends DatabaseAccessor<AppDatabase> with _$ProductDaoMixin {
ProductDao(super.db);
// Semua logika kueri produk dipusatkan di sini
Future<List<Product>> getAllProducts() => select(products).get();
Stream<List<Product>> watchAvailableProducts() {
return (select(products)..where((p) => p.isAvailable.equals(true))).watch();
}
Future<int> addProduct(ProductsCompanion entry) => into(products).insert(entry);
Future<bool> updateProduct(ProductsCompanion entry) {
return (update(products)..where((p) => p.id.equals(entry.id.value)))
.write(entry)
.then((rowsAffected) => rowsAffected > 0);
}
Future<void> deleteProduct(int id) {
return (delete(products)..where((p) => p.id.equals(id))).go();
}
}
Jelaskan pendaftaran DAO di kelas AppDatabase:
// Tambahkan di AppDatabase utama
@DriftDatabase(
tables: [Categories, Products, Orders, OrderItems],
daos: [ProductDao], // Daftarkan DAO di sini
)
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
// Getter untuk mengakses DAO dari luar secara instan
ProductDao get productDao => ProductDao(this);
@override
int get schemaVersion => 1;
}
Melalui pola DAO, struktur kode basis data kita menjadi modular. Pembuatan unit test juga menjadi lebih mudah karena kita bisa menguji fungsi-fungsi DAO secara terisolasi menggunakan database in-memory SQLite selama pengujian.
Integrasi State Management (Riverpod & BLoC) #
Langkah terakhir untuk menyempurnakan implementasi Drift di aplikasi kita adalah mengintegrasikannya dengan penyedia state management.
Integrasi Menggunakan Riverpod #
Kita menyediakan satu instance AppDatabase tunggal, menyediakan kelas-kelas DAO-nya, lalu memantau data reaktif menggunakan StreamProvider.
// lib/core/providers/database_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/app_database.dart';
import '../database/daos/product_dao.dart';
// Provider untuk instance database utama
final databaseProvider = Provider<AppDatabase>((ref) {
final db = AppDatabase();
// Pastikan database ditutup saat provider dihancurkan
ref.onDispose(() => db.close());
return db;
});
// Provider untuk ProductDao
final productDaoProvider = Provider<ProductDao>((ref) {
return ref.watch(databaseProvider).productDao;
});
// StreamProvider untuk memantau produk secara reaktif di UI
final availableProductsProvider = StreamProvider<List<Product>>((ref) {
final productDao = ref.watch(productDaoProvider);
return productDao.watchAvailableProducts();
});
Di UI widget, kita cukup menggunakan ref.watch(availableProductsProvider) untuk mendapatkan objek AsyncValue<List<Product>> yang siap dikonsumsi dengan menangani kondisi loading, error, dan data secara bersih.
Ringkasan #
- Drift adalah pustaka ORM yang tangguh untuk SQLite di Dart, menyediakan sistem penulisan tabel secara deklaratif dan verifikasi kueri saat kompilasi (compile-time type safety).
- Tabel & Kolom: Definisikan tabel dengan mewarisi kelas
Tabledan gunakan builder berantai untuk menyusun spesifikasi kolom secara mendetail.- Companion Classes: Gunakan Companion untuk operasi tulis dan pembaruan data untuk membedakan antara nilai yang absen, bernilai null eksplisit, atau bernilai baru.
- Reaktivitas bawaan: Cukup gunakan metode
watch()untuk mengubah kueri baca biasa menjadi aliran Stream reaktif yang memperbarui UI secara otomatis ketika database berubah.- JOIN Type-Safe: Hubungkan beberapa tabel dengan metode
join()dan uraikan hasilnya menggunakanrow.readTable()untuk menjaga tipe data tetap aman.- Pola DAO: Kelompokkan kueri database berdasarkan domain bisnis ke dalam kelas DAO untuk menjaga kodebase tetap modular, bersih, dan mudah diuji.
- Strategi Migrasi: Kelola pembaruan struktur database melalui kelas
MigrationStrategydengan mengidentifikasi peningkatan versi padaonUpgradedemi menjaga data pengguna tetap utuh.- Dukungan Web: Drift mendukung Flutter Web secara penuh menggunakan kompilasi SQLite WASM, menjadikannya pilihan database relasional multi-platform terbaik.