Using Drift (formerly moor) for Local Database Management in Flutter

In Flutter app development, managing local data efficiently is crucial for creating responsive and feature-rich applications. Drift, formerly known as Moor, is a powerful and reactive persistence library for Flutter and Dart. It allows you to define your database schema in Dart and automatically generates boilerplate code, making database operations type-safe and straightforward. This blog post provides a comprehensive guide on how to use Drift for local database management in Flutter.

What is Drift?

Drift is a persistence library for Dart and Flutter that simplifies working with SQLite databases. It offers a type-safe, reactive, and efficient way to interact with local databases. Key features of Drift include:

  • Type-Safe: Define database schemas in Dart and enjoy type-safe queries and data models.
  • Reactive: Provides streams of data changes, making it easy to build reactive UIs.
  • Code Generation: Automatically generates boilerplate code for database operations.
  • SQL DSL: Use a powerful SQL DSL (Domain Specific Language) to write complex queries.
  • Offline Support: Enables efficient local data caching and manipulation.

Why Use Drift?

  • Simplified Database Management: Automates the process of database schema definition and query generation.
  • Improved Performance: Provides efficient data access and caching mechanisms.
  • Reactive Programming: Integrates seamlessly with streams and reactive Flutter widgets.
  • Reduced Boilerplate Code: Minimizes the amount of manual SQL code you need to write.

How to Implement Drift in Flutter

To implement Drift in your Flutter project, follow these steps:

Step 1: Add Dependencies

Add the necessary dependencies to your pubspec.yaml file:

dependencies:
  drift: ^2.0.0
  sqlite3_flutter_libs: ^0.5.0 # Optional: Bundles SQLite for mobile platforms
  path_provider: ^2.0.0

dev_dependencies:
  drift_dev: ^2.0.0
  build_runner: ^2.0.0

Explanation of dependencies:

  • drift: The core Drift library.
  • sqlite3_flutter_libs: Bundles SQLite libraries for Flutter apps, making deployment easier (optional but recommended).
  • path_provider: Used to get platform-specific locations for storing the database.
  • drift_dev: The development tool for generating Drift code.
  • build_runner: A generic tool to run code generators like Drift.

Run flutter pub get to install the dependencies.

Step 2: Define Your Database Schema

Create a Dart file (e.g., database.dart) to define your database schema using Drift’s DSL.

import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;

part 'database.g.dart';

class Tasks extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text().withLength(min: 1, max: 50)();
  BoolColumn get completed => boolean().withDefault(const Constant(false))();
  DateTimeColumn get dueDate => dateTime().nullable()();
}

@DriftDatabase(tables: [Tasks])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 1;

  @override
  MigrationStrategy get migration => MigrationStrategy(
    onCreate: (Migrator m) async {
      await m.createAll();
    },
    onUpgrade: (Migrator m, int from, int to) async {
      // Add migration steps here if needed
    },
  );
}

LazyDatabase _openConnection() {
  return LazyDatabase(() async {
    final dbFolder = await getApplicationDocumentsDirectory();
    final file = File(p.join(dbFolder.path, 'db.sqlite'));
    return NativeDatabase.createInBackground(file);
  });
}

Explanation:

  • Tasks: A table definition extending Table from Drift.
  • id, name, completed, dueDate: Columns within the Tasks table with specified types and constraints.
  • AppDatabase: The main database class annotated with @DriftDatabase, specifying the tables it contains.
  • schemaVersion: The version of the database schema.
  • _openConnection: A helper function to open the SQLite database using NativeDatabase, which provides a platform-specific implementation.
  • drift_dev requires to name your class prefixed with _$ symbol (Ex: _$AppDatabase), Drift plugin renames with right ClassName on generate code

Step 3: Generate the Drift Code

Run the code generation tool to generate the necessary boilerplate code. Open your terminal, navigate to your Flutter project’s root directory, and run:

flutter pub run build_runner build

or for continuous code generation during development:

flutter pub run build_runner watch

This command processes your database.dart file and generates the corresponding database.g.dart file containing the necessary database classes and methods.

Step 4: Use the Generated Database Class

Now you can use the generated database class to perform database operations. Here’s how to perform CRUD (Create, Read, Update, Delete) operations:

Initialize the Database
import 'package:flutter/material.dart';
import 'package:drift_example/database.dart'; // Replace with your actual path

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  final AppDatabase database = AppDatabase();

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('Drift Example'),
        ),
        body: Center(
          child: Text('Drift Database Example'),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () {
            // Example CRUD operations will be added here
          },
          child: Icon(Icons.add),
        ),
      ),
    );
  }
  
  @override
  void dispose() {
    database.close();
    super.dispose();
  }
}
Create (Insert) Data
ElevatedButton(
  onPressed: () async {
    final task = TasksCompanion.insert(
      name: Value('Buy groceries'),
      dueDate: Value(DateTime.now().add(Duration(days: 2))),
    );
    await database.into(database.tasks).insert(task);
    print('Task added');
  },
  child: Text('Add Task'),
)
Read (Select) Data
FutureBuilder>(
  future: database.select(database.tasks).get(),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return CircularProgressIndicator();
    } else if (snapshot.hasError) {
      return Text('Error: ${snapshot.error}');
    } else if (snapshot.hasData) {
      final tasks = snapshot.data!;
      return ListView.builder(
        itemCount: tasks.length,
        itemBuilder: (context, index) {
          final task = tasks[index];
          return ListTile(
            title: Text(task.name),
            subtitle: Text('Due Date: ${task.dueDate}'),
          );
        },
      );
    } else {
      return Text('No tasks found');
    }
  },
)
Update Data
ElevatedButton(
  onPressed: () async {
    final taskToUpdate = Task(
      id: 1, // Replace with the actual task ID
      name: 'Updated task name',
      completed: true,
      dueDate: DateTime.now(),
    );
    await database.update(database.tasks).replace(taskToUpdate);
    print('Task updated');
  },
  child: Text('Update Task'),
)
Delete Data
ElevatedButton(
  onPressed: () async {
    await (database.delete(database.tasks)
          ..where((t) => t.id.equals(1))) // Replace 1 with the task ID
        .go();
    print('Task deleted');
  },
  child: Text('Delete Task'),
)

Step 5: Using Streams for Reactive Updates

Drift provides streams that emit data whenever changes occur in the database. This makes it easy to update your UI reactively.

StreamBuilder>(
  stream: database.select(database.tasks).watch(),
  builder: (context, snapshot) {
    if (snapshot.hasData) {
      final tasks = snapshot.data!;
      return ListView.builder(
        itemCount: tasks.length,
        itemBuilder: (context, index) {
          final task = tasks[index];
          return ListTile(
            title: Text(task.name),
            subtitle: Text('Due Date: ${task.dueDate}'),
          );
        },
      );
    } else {
      return CircularProgressIndicator();
    }
  },
)

In this example, database.select(database.tasks).watch() returns a Stream<List<Task>> that emits the latest list of tasks whenever the database changes.

Advanced Drift Features

Custom Queries

Drift allows you to write custom SQL queries for more complex data retrieval or manipulation. Use the customSelect method to execute SQL queries directly.

final result = await database.customSelect(
  'SELECT * FROM tasks WHERE completed = ?',
  variables: [Variable.withBool(true)],
).get();

for (final row in result) {
  print('Task Name: ${row.readString('name')}');
}

Transactions

Drift supports transactions to ensure data consistency. Use the transaction method to execute a series of database operations as a single atomic operation.

await database.transaction(() async {
  // Perform multiple database operations here
  await database.into(database.tasks).insert(
        TasksCompanion.insert(
          name: Value('Task 1'),
        ),
      );
  await database.into(database.tasks).insert(
        TasksCompanion.insert(
          name: Value('Task 2'),
        ),
      );
});

Conclusion

Drift (formerly Moor) is an excellent choice for local database management in Flutter, offering type safety, reactivity, and efficient code generation. By following the steps outlined in this guide, you can seamlessly integrate Drift into your Flutter projects, simplify your database operations, and improve your app’s performance and maintainability.