ORM Dart #
ORM (Object-Relational Mapping) adalah lapisan abstraksi yang memetakan baris database ke objek Dart dan sebaliknya — memungkinkan berinteraksi dengan database menggunakan kode Dart yang type-safe tanpa menulis SQL mentah. Ekosistem ORM Dart tidak sebesar Python (SQLAlchemy) atau JavaScript (Prisma, TypeORM), tapi ada beberapa pilihan yang solid untuk berbagai kebutuhan. Artikel ini membahas landscape ORM Dart, dua library yang paling matang (Drift dan Stormberry), dan kapan pendekatan query builder manual lebih baik dari ORM.
Landscape ORM Dart #
flowchart TD
A{Database target?} --> B{SQLite\nmobile/desktop lokal}
A --> C{PostgreSQL\nserver-side}
A --> D{Multi-database}
B --> E["Drift\npaling mature\ntype-safe\ncode gen"]
C --> F["Stormberry\ncode gen\nrelation support"]
C --> G["Query builder manual\n+ postgres package\nkontrol penuh"]
D --> H["Conduit ORM\nbagian dari framework"]
D --> I["Angel ORM\nbagian dari framework"]
| Library | Database | Status | Fitur Utama |
|---|---|---|---|
| Drift | SQLite | ✅ Aktif, sangat mature | Type-safe, reactive, code gen, migration |
| Stormberry | PostgreSQL | ✅ Aktif | Code gen, relasi, annotation-based |
| Conduit ORM | PostgreSQL | ✅ (bagian Conduit) | Terintegrasi dengan framework |
| Angel ORM | PostgreSQL, MySQL | ✅ (bagian Angel3) | Terintegrasi dengan framework |
| Query builder manual | Semua | — | Kontrol penuh, tidak ada abstraksi |
Drift — ORM untuk SQLite #
Drift (sebelumnya disebut Moor) adalah ORM paling mature di ekosistem Dart — khusus untuk SQLite dan digunakan sangat luas di aplikasi Flutter untuk penyimpanan lokal. Drift menggunakan code generation dan menyediakan query yang benar-benar type-safe.
Setup #
dart pub add drift drift_flutter
dart pub add dev:drift_dev build_runner
# pubspec.yaml
dependencies:
drift: ^2.14.0
drift_flutter: ^0.1.0 # untuk Flutter (SQLite native)
# sqlite3_flutter_libs: ^0.5.0 # native SQLite untuk mobile
# drift/native.dart: untuk Dart murni (server/CLI)
dev_dependencies:
drift_dev: ^2.14.0
build_runner: ^2.4.0
Definisi Tabel dan Database #
// lib/database/database.dart
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
// Bagian yang di-generate oleh build_runner
part 'database.g.dart';
// Definisi tabel — mirip dengan schema SQL tapi dalam Dart
class Produk extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get nama => text().withLength(min: 1, max: 200)();
TextColumn get deskripsi => text().nullable()();
RealColumn get harga => real()();
IntColumn get stok => integer().withDefault(const Constant(0))();
TextColumn get kategori => text()();
BoolColumn get aktif => boolean().withDefault(const Constant(true))();
DateTimeColumn get dibuatPada => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get diubahPada => dateTime().nullable()();
}
class Kategori extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get nama => text().unique()();
TextColumn get deskripsi => text().nullable()();
}
// Definisi database — daftar semua tabel
@DriftDatabase(tables: [Produk, Kategori])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
// Versi skema — increment saat ada perubahan tabel
@override
int get schemaVersion => 1;
// Migration saat skema berubah
@override
MigrationStrategy get migration {
return MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll(); // buat semua tabel untuk instalasi baru
},
onUpgrade: (Migrator m, int from, int to) async {
if (from < 2) {
// Upgrade dari v1 ke v2 — tambah kolom baru
await m.addColumn(produk, produk.diubahPada);
}
if (from < 3) {
// Upgrade dari v2 ke v3
await m.createTable(kategori);
}
},
);
}
}
// Buka koneksi SQLite
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(path.join(dbFolder.path, 'app.sqlite'));
return NativeDatabase.createInBackground(file);
});
}
# Generate kode dari definisi tabel
dart run build_runner build
# atau watch mode
dart run build_runner watch
CRUD dengan Drift #
// Semua operasi type-safe — kelas ProdukData di-generate otomatis
class ProdukRepository {
final AppDatabase _db;
ProdukRepository(this._db);
// SELECT semua produk aktif
Future<List<ProdukData>> ambilSemua() {
return (_db.select(_db.produk)
..where((p) => p.aktif.equals(true))
..orderBy([(p) => OrderingTerm.desc(p.dibuatPada)]))
.get();
}
// SELECT satu produk
Future<ProdukData?> ambilById(int id) {
return (_db.select(_db.produk)
..where((p) => p.id.equals(id)))
.getSingleOrNull();
}
// Reactive query — Stream yang update otomatis saat data berubah
Stream<List<ProdukData>> watchProdukAktif() {
return (_db.select(_db.produk)
..where((p) => p.aktif.equals(true))
..orderBy([(p) => OrderingTerm.asc(p.nama)]))
.watch();
}
// INSERT
Future<int> tambah(ProdukCompanion produk) {
return _db.into(_db.produk).insert(produk);
}
Future<int> tambahProduk({
required String nama,
required double harga,
required int stok,
required String kategori,
}) {
return _db.into(_db.produk).insert(
ProdukCompanion.insert(
nama: nama,
harga: harga,
stok: stok,
kategori: kategori,
),
);
}
// UPDATE
Future<bool> perbarui(ProdukData produk) {
return _db.update(_db.produk).replace(produk);
}
Future<int> updateHarga(int id, double hargaBaru) {
return (_db.update(_db.produk)
..where((p) => p.id.equals(id)))
.write(ProdukCompanion(
harga: Value(hargaBaru),
diubahPada: Value(DateTime.now()),
));
}
// DELETE
Future<int> hapus(int id) {
return (_db.delete(_db.produk)..where((p) => p.id.equals(id))).go();
}
// TRANSACTION
Future<void> pindahStok(int dari, int ke, int jumlah) async {
await _db.transaction(() async {
// Kurangi stok sumber
final sumber = await ambilById(dari);
if (sumber == null || sumber.stok < jumlah) {
throw Exception('Stok tidak mencukupi');
}
await updateStok(dari, sumber.stok - jumlah);
// Tambah stok tujuan
final tujuan = await ambilById(ke);
if (tujuan == null) throw Exception('Produk tujuan tidak ditemukan');
await updateStok(ke, tujuan.stok + jumlah);
});
}
Future<int> updateStok(int id, int stokBaru) {
return (_db.update(_db.produk)..where((p) => p.id.equals(id)))
.write(ProdukCompanion(stok: Value(stokBaru)));
}
}
Custom Query dengan SQL di Drift #
// Kadang perlu SQL kustom untuk query kompleks
class ProdukRepository {
// Custom SELECT dengan join
Future<List<ProdukDenganKategori>> ambilDenganKategori() {
final query = _db.select(_db.produk).join([
innerJoin(_db.kategori, _db.kategori.id.equalsExp(_db.produk.id)),
]);
return query.map((row) => ProdukDenganKategori(
produk: row.readTable(_db.produk),
kategori: row.readTable(_db.kategori),
)).get();
}
// Raw SQL query — untuk kasus yang tidak bisa diekspresikan dengan builder
Future<List<Map<String, Object?>>> statistikPerKategori() {
return _db.customSelect(
'SELECT kategori, COUNT(*) as total, AVG(harga) as rata_harga '
'FROM produk WHERE aktif = 1 GROUP BY kategori',
).get().then((rows) => rows.map((row) => row.data).toList());
}
}
Stormberry — ORM untuk PostgreSQL #
Stormberry adalah ORM khusus PostgreSQL dengan pendekatan annotation-based dan code generation, cocok untuk Dart server-side.
Setup #
dart pub add stormberry
dart pub add dev:stormberry_generator build_runner
# pubspec.yaml
dependencies:
stormberry: ^0.17.0
postgres: ^3.4.0 # Stormberry menggunakan postgres package
dev_dependencies:
stormberry_generator: ^0.17.0
build_runner: ^2.4.0
Definisi Model #
// lib/model/produk.dart
import 'package:stormberry/stormberry.dart';
// Anotasi @Model menandai kelas sebagai entity database
@Model()
abstract class Produk {
@PrimaryKey()
int get id;
String get nama;
String? get deskripsi;
double get harga;
int get stok;
String get kategori;
bool get aktif;
@DateTimeColumn()
DateTime get dibuatPada;
// Relasi many-to-one ke Kategori
@BelongsTo()
KategoriModel? get kategoriObj;
}
@Model()
abstract class KategoriModel {
@PrimaryKey()
int get id;
String get nama;
// Relasi one-to-many — satu kategori punya banyak produk
@HasMany()
List<ProdukView>? get produk;
}
// View untuk response yang berbeda dari model penuh
@ModelView(Produk)
abstract class ProdukRingkasan {
int get id;
String get nama;
double get harga;
}
# Generate kode
dart run build_runner build
Menggunakan Stormberry #
import 'package:stormberry/stormberry.dart';
import 'package:postgres/postgres.dart';
Future<void> main() async {
// Buat koneksi database
final db = Database(
host: 'localhost',
port: 5432,
database: 'toko',
user: 'postgres',
password: 'password',
);
// CRUD via repository yang di-generate
final repo = db.produk;
// INSERT
await repo.insertOne(ProdukInsertRequest(
nama: 'Laptop Gaming',
harga: 15000000,
stok: 10,
kategori: 'elektronik',
aktif: true,
dibuatPada: DateTime.now().toUtc(),
));
// SELECT semua
final semuaProduk = await repo.queryProduk(
ProdukQuery(where: 'aktif = true', orderBy: 'dibuat_pada DESC'),
);
// SELECT dengan view ringkasan (subset field)
final ringkasan = await repo.queryProdukRingkasan();
// SELECT satu
final produk = await repo.queryProduk(
ProdukQuery(where: 'id = 1'),
);
// UPDATE
await repo.updateOne(ProdukUpdateRequest(
id: 1,
harga: 14000000,
));
// DELETE
await repo.deleteOne(1);
await db.close();
}
Query Builder Manual — Kapan Lebih Baik dari ORM #
ORM tidak selalu pilihan terbaik. Ada situasi di mana query builder manual lebih tepat:
// lib/repository/produk_repository.dart
import 'package:postgres/postgres.dart';
class ProdukRepository {
final Pool _pool;
ProdukRepository(this._pool);
// Query kompleks yang ORM kesulitan mengekspresikan
Future<List<Map<String, dynamic>>> laporanPenjualan({
required DateTime dari,
required DateTime hingga,
}) async {
final result = await _pool.execute(
'''
SELECT
p.id,
p.nama AS nama_produk,
p.kategori,
COUNT(oi.id) AS jumlah_transaksi,
SUM(oi.qty) AS total_terjual,
SUM(oi.subtotal) AS total_pendapatan,
AVG(oi.harga) AS harga_rata_rata,
MIN(o.dibuat_pada) AS transaksi_pertama,
MAX(o.dibuat_pada) AS transaksi_terakhir
FROM produk p
INNER JOIN order_items oi ON oi.id_produk = p.id
INNER JOIN orders o ON o.id = oi.id_order
WHERE
o.status = 'selesai'
AND o.dibuat_pada BETWEEN \$1 AND \$2
GROUP BY p.id, p.nama, p.kategori
ORDER BY total_pendapatan DESC
''',
parameters: [dari, hingga],
);
return result.map((row) => row.toColumnMap()).toList();
}
}
GUNAKAN ORM (Drift/Stormberry) ketika:
✓ CRUD sederhana tanpa query kompleks
✓ Butuh type-safety dan autocomplete dari IDE
✓ Butuh migration yang dikelola framework
✓ Tim tidak familiar dengan SQL
✓ Drift: aplikasi Flutter dengan penyimpanan lokal SQLite
✓ Stormberry: server-side PostgreSQL dengan relasi sederhana
GUNAKAN query builder manual ketika:
✓ Query sangat kompleks (JOIN berlapis, window function, CTE)
✓ Butuh kontrol penuh atas SQL yang dihasilkan
✓ Performa kritis — ingin memastikan tidak ada N+1 atau query buruk
✓ Tim memiliki pengetahuan SQL yang kuat
✓ Database non-standar atau fitur database spesifik
Perbandingan Drift vs Stormberry #
| Aspek | Drift | Stormberry |
|---|---|---|
| Database | SQLite | PostgreSQL |
| Target | Mobile/Desktop (Flutter) | Server-side |
| Reactive | ✓ watch() → Stream |
✗ |
| Migration | ✓ Built-in manual | ✓ Auto-generate |
| Relasi | ✓ Join support | ✓ BelongsTo/HasMany |
| Raw SQL | ✓ customSelect() |
✓ Via postgres package |
| Maturity | ⭐⭐⭐⭐⭐ Sangat mature | ⭐⭐⭐ Berkembang |
| Dokumentasi | Sangat lengkap | Cukup lengkap |
Ringkasan #
- Drift adalah pilihan terbaik untuk SQLite — sangat mature, type-safe, mendukung reactive query (
watch()) yang sangat berguna di Flutter, dan migration yang terkelola.- Stormberry untuk PostgreSQL server-side dengan annotation-based model — lebih sederhana dari Conduit ORM tapi tidak terikat ke framework tertentu.
- Conduit ORM / Angel ORM adalah pilihan tepat jika sudah menggunakan framework tersebut — terintegrasi erat dan tidak perlu konfigurasi tambahan.
- Query builder manual dengan
postgrespackage lebih tepat untuk query analitik kompleks, laporan, dan kasus di mana performa adalah prioritas utama.- Drift
watch()mengembalikan Stream yang update otomatis saat data di database berubah — fitur killer untuk aplikasi Flutter yang butuh UI reaktif.- Code generation adalah fondasi semua ORM Dart yang mature —
dart run build_runner buildwajib dijalankan setelah mengubah definisi tabel atau model.- Migration Drift harus ditulis manual di
onUpgradecallback — incrementschemaVersiondan tambahkan logika upgrade. Lebih verbose tapi memberikan kontrol penuh.- Stormberry View memungkinkan mendefinisikan subset field yang berbeda untuk berbagai konteks query — tanpa harus selalu fetch semua kolom.
- ORM bukan silver bullet — untuk query yang sangat kompleks (laporan, agregasi berlapis), SQL langsung lebih mudah dibaca dan di-maintain daripada mencoba mengekspresikannya melalui ORM API.
- N+1 query problem berlaku di semua ORM — selalu periksa query yang di-generate dan pastikan relasi di-fetch dengan JOIN bukan loop query terpisah.