MySQL

MySQL #

MySQL adalah salah satu database relasional paling populer di dunia, dan Dart menyediakan akses ke MySQL melalui package mysql_client — implementasi native Dart tanpa dependensi native library. Package ini mendukung connection pooling, prepared statements, transaksi, dan bekerja sepenuhnya secara asinkron sesuai dengan model event loop Dart. Artikel ini membahas dari koneksi dasar hingga pola repository yang bersih dan aman dari SQL injection.

Setup Package #

dart pub add mysql_client
# pubspec.yaml
dependencies:
  mysql_client: ^0.0.27

Koneksi ke MySQL #

import 'package:mysql_client/mysql_client.dart';

Future<void> main() async {
  // Koneksi tunggal — untuk penggunaan sederhana atau CLI
  final conn = await MySQLConnection.createConnection(
    host: 'localhost',
    port: 3306,
    userName: 'root',
    password: 'password',
    databaseName: 'toko_online',
    secure: false,  // true untuk SSL
  );

  await conn.connect();
  print('Terhubung ke MySQL');

  // Selalu tutup koneksi setelah selesai
  await conn.close();
}

Connection Pool — untuk Server #

Untuk aplikasi server yang menangani banyak request, selalu gunakan connection pool — membuat koneksi baru untuk setiap request sangat lambat:

import 'package:mysql_client/mysql_client.dart';

class DatabasePool {
  static MySQLClientPool? _pool;

  static Future<MySQLClientPool> dapatkan() async {
    _pool ??= MySQLClientPool(
      host: 'localhost',
      port: 3306,
      userName: 'appuser',
      password: 'password',
      databaseName: 'toko_online',
      maxConnections: 10,    // maksimal koneksi simultan
    );
    return _pool!;
  }

  static Future<void> tutup() async {
    await _pool?.close();
    _pool = null;
  }
}

// Penggunaan di handler request
Future<void> tanganiRequest() async {
  final pool = await DatabasePool.dapatkan();

  // execute mengambil koneksi dari pool, lalu mengembalikannya otomatis
  final result = await pool.execute('SELECT * FROM produk WHERE aktif = 1');
  // ...
}

Query Dasar #

SELECT #

import 'package:mysql_client/mysql_client.dart';

Future<void> contohSelect(MySQLConnection conn) async {
  // Query sederhana
  final result = await conn.execute('SELECT * FROM pengguna');

  // Iterasi baris hasil
  for (final row in result.rows) {
    print('ID: ${row.colByName('id')}');
    print('Nama: ${row.colByName('nama')}');
    print('Email: ${row.colByName('email')}');
  }

  print('Total: ${result.numOfRows} baris');

  // Akses via indeks kolom
  for (final row in result.rows) {
    print(row.colAt(0));  // kolom pertama
    print(row.colAt(1));  // kolom kedua
  }

  // Konversi ke Map
  for (final row in result.rows) {
    final map = row.assoc(); // Map<String, String?>
    print(map['nama']);
    print(map['email']);
  }
}

Parameterized Query — Wajib untuk Keamanan #

Selalu gunakan parameterized query — jangan pernah menyisipkan nilai langsung ke query string. Parameterized query mencegah SQL injection:

// ANTI-PATTERN: string interpolation langsung — rentan SQL injection!
final input = "'; DROP TABLE pengguna; --";
await conn.execute(
  "SELECT * FROM pengguna WHERE nama = '$input'",  // ✗ sangat berbahaya!
);

// BENAR: parameterized query — nilai dikirim terpisah, bukan disematkan
final namaInput = "'; DROP TABLE pengguna; --";
final result = await conn.execute(
  'SELECT * FROM pengguna WHERE nama = :nama',
  {'nama': namaInput},  // aman — driver yang handle escaping
);
// Query dieksekusi sebagai: WHERE nama = "'; DROP TABLE pengguna; --"
// Bukan sebagai perintah SQL
// Berbagai contoh parameterized query
Future<void> contohParameterized(MySQLConnection conn) async {
  // SELECT dengan multiple parameter
  final produk = await conn.execute(
    'SELECT * FROM produk WHERE kategori = :kat AND harga <= :maks AND aktif = 1',
    {'kat': 'elektronik', 'maks': 5000000},
  );

  // LIKE dengan parameter — % ditambahkan di kode, bukan di SQL
  final kata = 'laptop';
  final cari = await conn.execute(
    'SELECT * FROM produk WHERE nama LIKE :keyword',
    {'keyword': '%$kata%'},
  );

  // IN clause — tidak bisa langsung dengan satu parameter
  // Harus buat placeholder dinamis
  final ids = [1, 2, 3, 4, 5];
  final placeholders = ids.asMap().entries
      .map((e) => ':id${e.key}')
      .join(', ');
  final paramsIn = {for (var e in ids.asMap().entries) 'id${e.key}': e.value};
  final byIds = await conn.execute(
    'SELECT * FROM produk WHERE id IN ($placeholders)',
    paramsIn,
  );
}

CRUD Lengkap #

import 'package:mysql_client/mysql_client.dart';

class ProdukRepository {
  final MySQLClientPool _pool;

  ProdukRepository(this._pool);

  // CREATE
  Future<int> tambah(String nama, double harga, int stok) async {
    final result = await _pool.execute(
      'INSERT INTO produk (nama, harga, stok, aktif, dibuat_pada) '
      'VALUES (:nama, :harga, :stok, 1, NOW())',
      {'nama': nama, 'harga': harga, 'stok': stok},
    );
    return result.lastInsertID.toInt(); // ID yang baru dibuat
  }

  // READ semua
  Future<List<Map<String, String?>>> ambilSemua({
    int halaman = 1,
    int perHalaman = 20,
  }) async {
    final offset = (halaman - 1) * perHalaman;
    final result = await _pool.execute(
      'SELECT id, nama, harga, stok FROM produk '
      'WHERE aktif = 1 '
      'ORDER BY dibuat_pada DESC '
      'LIMIT :limit OFFSET :offset',
      {'limit': perHalaman, 'offset': offset},
    );
    return result.rows.map((row) => row.assoc()).toList();
  }

  // READ satu
  Future<Map<String, String?>?> ambilById(int id) async {
    final result = await _pool.execute(
      'SELECT * FROM produk WHERE id = :id AND aktif = 1',
      {'id': id},
    );
    if (result.numOfRows == 0) return null;
    return result.rows.first.assoc();
  }

  // UPDATE
  Future<bool> perbarui(int id, {String? nama, double? harga, int? stok}) async {
    // Bangun query dinamis berdasarkan field yang diupdate
    final fields = <String>[];
    final params = <String, dynamic>{'id': id};

    if (nama != null) { fields.add('nama = :nama'); params['nama'] = nama; }
    if (harga != null) { fields.add('harga = :harga'); params['harga'] = harga; }
    if (stok != null) { fields.add('stok = :stok'); params['stok'] = stok; }

    if (fields.isEmpty) return false;

    final result = await _pool.execute(
      'UPDATE produk SET ${fields.join(', ')} WHERE id = :id',
      params,
    );
    return result.affectedRows.toInt() > 0;
  }

  // DELETE (soft delete)
  Future<bool> hapus(int id) async {
    final result = await _pool.execute(
      'UPDATE produk SET aktif = 0 WHERE id = :id',
      {'id': id},
    );
    return result.affectedRows.toInt() > 0;
  }

  // Hitung total
  Future<int> hitung({String? kategori}) async {
    String query = 'SELECT COUNT(*) as total FROM produk WHERE aktif = 1';
    final params = <String, dynamic>{};

    if (kategori != null) {
      query += ' AND kategori = :kategori';
      params['kategori'] = kategori;
    }

    final result = await _pool.execute(query, params);
    return int.parse(result.rows.first.colByName('total') ?? '0');
  }
}

Mapping ke Model Kelas #

row.assoc() mengembalikan Map<String, String?> — semua nilai adalah String. Konversi ke tipe yang tepat diperlukan:

class Produk {
  final int id;
  final String nama;
  final double harga;
  final int stok;
  final bool aktif;
  final DateTime dibuatPada;

  const Produk({
    required this.id,
    required this.nama,
    required this.harga,
    required this.stok,
    required this.aktif,
    required this.dibuatPada,
  });

  // Mapping dari hasil query MySQL
  factory Produk.dariRow(ResultSetRow row) {
    final map = row.assoc();
    return Produk(
      id: int.parse(map['id'] ?? '0'),
      nama: map['nama'] ?? '',
      harga: double.parse(map['harga'] ?? '0'),
      stok: int.parse(map['stok'] ?? '0'),
      aktif: map['aktif'] == '1',
      dibuatPada: DateTime.parse(map['dibuat_pada'] ?? DateTime.now().toString()),
    );
  }

  @override
  String toString() => 'Produk($id: $nama, Rp${harga.toStringAsFixed(0)})';
}

// Repository dengan mapping model
class ProdukRepositoryTyped {
  final MySQLClientPool _pool;

  ProdukRepositoryTyped(this._pool);

  Future<List<Produk>> ambilSemua() async {
    final result = await _pool.execute(
      'SELECT * FROM produk WHERE aktif = 1 ORDER BY nama',
    );
    return result.rows.map(Produk.dariRow).toList();
  }

  Future<Produk?> ambilById(int id) async {
    final result = await _pool.execute(
      'SELECT * FROM produk WHERE id = :id',
      {'id': id},
    );
    if (result.numOfRows == 0) return null;
    return Produk.dariRow(result.rows.first);
  }
}

Transaksi #

Transaksi memastikan beberapa operasi berhasil semua atau gagal semua — prinsip atomicity:

Future<void> buatOrder(
  MySQLConnection conn,
  int idPengguna,
  List<({int idProduk, int qty})> items,
) async {
  await conn.transactional((txConn) async {
    // 1. Buat order
    final orderResult = await txConn.execute(
      'INSERT INTO order (id_pengguna, status, dibuat_pada) VALUES (:uid, "pending", NOW())',
      {'uid': idPengguna},
    );
    final idOrder = orderResult.lastInsertID.toInt();

    double totalHarga = 0;

    for (final item in items) {
      // 2. Cek stok (dengan SELECT FOR UPDATE — kunci baris)
      final stokResult = await txConn.execute(
        'SELECT stok, harga FROM produk WHERE id = :id FOR UPDATE',
        {'id': item.idProduk},
      );

      if (stokResult.numOfRows == 0) {
        throw Exception('Produk ${item.idProduk} tidak ditemukan');
      }

      final row = stokResult.rows.first.assoc();
      final stokTersedia = int.parse(row['stok'] ?? '0');
      final harga = double.parse(row['harga'] ?? '0');

      if (stokTersedia < item.qty) {
        throw Exception(
          'Stok produk ${item.idProduk} tidak mencukupi '
          '(tersedia: $stokTersedia, diminta: ${item.qty})',
        );
      }

      // 3. Kurangi stok
      await txConn.execute(
        'UPDATE produk SET stok = stok - :qty WHERE id = :id',
        {'qty': item.qty, 'id': item.idProduk},
      );

      // 4. Tambah item order
      final subtotal = harga * item.qty;
      await txConn.execute(
        'INSERT INTO order_item (id_order, id_produk, qty, harga, subtotal) '
        'VALUES (:oid, :pid, :qty, :harga, :subtotal)',
        {
          'oid': idOrder,
          'pid': item.idProduk,
          'qty': item.qty,
          'harga': harga,
          'subtotal': subtotal,
        },
      );

      totalHarga += subtotal;
    }

    // 5. Update total order
    await txConn.execute(
      'UPDATE order SET total = :total WHERE id = :id',
      {'total': totalHarga, 'id': idOrder},
    );

    print('Order #$idOrder berhasil dibuat, total: Rp${totalHarga.toStringAsFixed(0)}');

    // Jika ada exception di manapun di dalam transactional(),
    // semua perubahan di-rollback otomatis
  });
}

Penanganan Error #

import 'package:mysql_client/mysql_client.dart';

Future<Produk?> ambilProdukAman(MySQLClientPool pool, int id) async {
  try {
    final result = await pool.execute(
      'SELECT * FROM produk WHERE id = :id',
      {'id': id},
    );
    if (result.numOfRows == 0) return null;
    return Produk.dariRow(result.rows.first);

  } on MySQLException catch (e) {
    // Error dari MySQL server (query salah, koneksi putus, dll.)
    print('MySQL Error ${e.message}');
    rethrow;

  } on MySQLClientException catch (e) {
    // Error dari client (koneksi timeout, pool penuh, dll.)
    print('Client Error: $e');
    rethrow;
  }
}

// Retry dengan exponential backoff untuk koneksi yang tidak stabil
Future<T> denganRetry<T>(
  Future<T> Function() operasi, {
  int maxCoba = 3,
}) async {
  for (int i = 0; i < maxCoba; i++) {
    try {
      return await operasi();
    } on MySQLClientException catch (e) {
      if (i == maxCoba - 1) rethrow;
      final tunda = Duration(seconds: 1 << i);
      print('Koneksi gagal, retry dalam ${tunda.inSeconds}s: $e');
      await Future.delayed(tunda);
    }
  }
  throw StateError('Tidak terjangkau');
}

Anti-Pattern MySQL di Dart #

N+1 Query Problem #

// ANTI-PATTERN: N+1 — satu query untuk list, N query untuk detail
Future<List<OrderDenganItems>> buruk(MySQLClientPool pool) async {
  final orders = await pool.execute('SELECT * FROM orders');
  final hasil = <OrderDenganItems>[];

  for (final orderRow in orders.rows) {
    final id = orderRow.colByName('id');
    // ✗ query terpisah untuk setiap order — N+1!
    final items = await pool.execute(
      'SELECT * FROM order_items WHERE id_order = :id',
      {'id': id},
    );
    hasil.add(OrderDenganItems(orderRow, items.rows.toList()));
  }
  return hasil;
}

// BENAR: satu query dengan JOIN
Future<void> baik(MySQLClientPool pool) async {
  final result = await pool.execute('''
    SELECT o.*, oi.id_produk, oi.qty, oi.subtotal, p.nama as nama_produk
    FROM orders o
    LEFT JOIN order_items oi ON oi.id_order = o.id
    LEFT JOIN produk p ON p.id = oi.id_produk
    WHERE o.id_pengguna = :uid
    ORDER BY o.id, oi.id
  ''', {'uid': 1});

  // Kelompokkan di Dart
  final ordersMap = <int, Map<String, dynamic>>{};
  for (final row in result.rows) {
    final map = row.assoc();
    final idOrder = int.parse(map['id'] ?? '0');
    ordersMap.putIfAbsent(idOrder, () => {
      'order': map,
      'items': <Map<String, String?>>[],
    });
    if (map['id_produk'] != null) {
      (ordersMap[idOrder]!['items'] as List).add(map);
    }
  }
}

Tidak Menggunakan Connection Pool di Server #

// ANTI-PATTERN: buat koneksi baru per request di server
Future<Response> handler(Request req) async {
  // ✗ membuat koneksi baru setiap request — sangat lambat!
  final conn = await MySQLConnection.createConnection(host: 'localhost', /* ... */);
  await conn.connect();
  final data = await conn.execute('SELECT ...');
  await conn.close();
  return Response.ok(data);
}

// BENAR: singleton pool, reuse koneksi
final _pool = MySQLClientPool(host: 'localhost', /* ... */, maxConnections: 10);

Future<Response> handler(Request req) async {
  // ✓ ambil koneksi dari pool, kembalikan otomatis setelah selesai
  final data = await _pool.execute('SELECT ...');
  return Response.ok(data);
}

Ringkasan #

  • Selalu gunakan parameterized query dengan :namaParam — jangan pernah menyisipkan nilai user input langsung ke string query. SQL injection adalah celah keamanan yang sangat berbahaya.
  • Connection pool untuk server (MySQLClientPool) — membuat koneksi baru per request sangat lambat. Pool mengelola koneksi yang bisa digunakan ulang.
  • result.rows.map(Model.dariRow) untuk mapping ke model kelas — row.assoc() mengembalikan Map<String, String?> sehingga perlu konversi tipe (int.parse, double.parse, dll.).
  • Transaksi dengan transactional() — jika ada exception di dalam callback, semua perubahan di-rollback otomatis. Gunakan SELECT FOR UPDATE untuk kunci baris yang akan diubah.
  • Soft delete lebih baik dari hard delete — tambahkan kolom aktif atau dihapus_pada, sehingga data bisa dipulihkan dan audit trail tetap ada.
  • Hindari N+1 query — jangan query di dalam loop. Gunakan JOIN untuk mengambil data yang terkait dalam satu query, lalu kelompokkan hasilnya di Dart.
  • Query dinamis untuk UPDATE parsial — bangun daftar field dan parameter secara programatik, jangan buat method terpisah untuk setiap kombinasi field yang mungkin.
  • IN clause tidak bisa menggunakan satu parameter array — buat placeholder dinamis (:id0, :id1, :id2) sesuai jumlah elemen.
  • Tangkap MySQLException secara spesifik untuk error server (query salah, constraint violation) dan MySQLClientException untuk error koneksi.
  • Retry dengan exponential backoff untuk operasi database yang gagal karena koneksi — tunggu 1, 2, 4 detik sebelum mencoba ulang.

← Sebelumnya: YAML   Berikutnya: MSSQL →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact