FlutterでORM Driftを利用してみる

## はじめに
表題通り、FlutterでORMのDriftを利用していきます。

## Driftとは
Dart および Flutter アプリケーション用の強力なデータベース ライブラリです。Daoクラスで定義したメソッドに関連するコードや、テーブルとそのカラムに対応するオブジェクトを自動生成します。
![スクリーンショット 2024-08-10 16.43.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599049/b1152606-db4a-1ac6-f9bc-b8b6907a2157.png)

https://drift.simonbinder.eu/

## ソースコード

```yml:pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  drift: ^2.0.0 # Driftのライブラリ

  sqlite3_flutter_libs: ^0.5.24 # sqliteのライブラリ
  
  path_provider: ^2.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter

  drift_dev: ^2.0.0 #driftの自動生成用のライブラリ
  
  build_runner: ^2.1.5 #自動生成用のライブラリ(`.g.dart` files)
```
:::note warn
`sqlite3_flutter_libs`のversionを^0.6にした場合、ビルドエラー発生。
drift_devの2.0と相性が悪いようです😅
:::



```shell:ディレクトリ構成
lib/infra/repository
├── app_database.dart        // データベースの設定と管理
├── app_database.g.dart      // Driftのコードジェネレーターによって自動生成されたファイル
├── tables.dart              // データベース内のテーブル定義
├── todo_dao.dart            // DAO (Data Access Object) の定義
└── todo_dao.g.dart          // Driftのコードジェネレーターによって自動生成されたファイル
```

```dart:lib/infra/repository/app_database.dart
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'dart:io';

import 'tables.dart'; // テーブル定義をインポート
import 'todo_dao.dart'; // DAOをインポート

part 'app_database.g.dart';

@DriftDatabase(tables: [Todos], daos: [TodoDao])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 1;
}

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


```dart:lib/infra/repository/tables.dart
import 'package:drift/drift.dart';

class Todos extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text().withLength(min: 1, max: 50)();
  TextColumn get content => text().named('body')();
  DateTimeColumn get dueDate => dateTime().nullable()();
}
```
:::note info
モデルからテーブルを構築するための定義となります。
:::

```dart:lib/infra/repository/todo_dao.dart
import 'package:drift/drift.dart';
import 'app_database.dart';
import 'tables.dart';
part 'todo_dao.g.dart';

@DriftAccessor(tables: [Todos])
class TodoDao extends DatabaseAccessor<AppDatabase> with _$TodoDaoMixin {
  TodoDao(AppDatabase db) : super(db);

  Future<List<Todo>> getAllTodos() => select(todos).get();
  Stream<List<Todo>> watchAllTodos() => select(todos).watch();
  Future insertTodo(Insertable<Todo> todo) => into(todos).insert(todo);
  Future updateTodo(Insertable<Todo> todo) => update(todos).replace(todo);
  Future deleteTodo(Insertable<Todo> todo) => delete(todos).delete(todo);
}
```

:::note info
CRUD処理をmodelの定義から記載しています。
daoを元にSQLを発行するモデルを自動生成生成します。
:::

ここまでで準備OKです。では自動生成を実行していきます。

```shell:
flutter pub run build_runner build
```
すると、下記2つのコードが自動生成されます。

```dart
・lib/infra/repository/app_database.g.dart
・lib/infra/repository/todo_dao.g.dart
```

```dart:lib/infra/repository/app_database.g.dart
// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'app_database.dart';

// ignore_for_file: type=lint
class $TodosTable extends Todos with TableInfo<$TodosTable, Todo> {
  @override
  final GeneratedDatabase attachedDatabase;
  final String? _alias;
  $TodosTable(this.attachedDatabase, [this._alias]);
  static const VerificationMeta _idMeta = const VerificationMeta('id');
  @override
  late final GeneratedColumn<int> id = GeneratedColumn<int>(
      'id', aliasedName, false,
      hasAutoIncrement: true,
      type: DriftSqlType.int,
      requiredDuringInsert: false,
      defaultConstraints:
          GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
  static const VerificationMeta _titleMeta = const VerificationMeta('title');
  @override
  late final GeneratedColumn<String> title = GeneratedColumn<String>(
      'title', aliasedName, false,
      additionalChecks:
          GeneratedColumn.checkTextLength(minTextLength: 1, maxTextLength: 50),
      type: DriftSqlType.string,
      requiredDuringInsert: true);
  static const VerificationMeta _contentMeta =
      const VerificationMeta('content');
  @override
  late final GeneratedColumn<String> content = GeneratedColumn<String>(
      'body', aliasedName, false,
      type: DriftSqlType.string, requiredDuringInsert: true);
  static const VerificationMeta _dueDateMeta =
      const VerificationMeta('dueDate');
  @override
  late final GeneratedColumn<DateTime> dueDate = GeneratedColumn<DateTime>(
      'due_date', aliasedName, true,
      type: DriftSqlType.dateTime, requiredDuringInsert: false);
  @override
  List<GeneratedColumn> get $columns => [id, title, content, dueDate];
  @override
  String get aliasedName => _alias ?? actualTableName;
  @override
  String get actualTableName => $name;
  static const String $name = 'todos';
  @override
  VerificationContext validateIntegrity(Insertable<Todo> instance,
      {bool isInserting = false}) {
    final context = VerificationContext();
    final data = instance.toColumns(true);
    if (data.containsKey('id')) {
      context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
    }
    if (data.containsKey('title')) {
      context.handle(
          _titleMeta, title.isAcceptableOrUnknown(data['title']!, _titleMeta));
    } else if (isInserting) {
      context.missing(_titleMeta);
    }
    if (data.containsKey('body')) {
      context.handle(_contentMeta,
          content.isAcceptableOrUnknown(data['body']!, _contentMeta));
    } else if (isInserting) {
      context.missing(_contentMeta);
    }
    if (data.containsKey('due_date')) {
      context.handle(_dueDateMeta,
          dueDate.isAcceptableOrUnknown(data['due_date']!, _dueDateMeta));
    }
    return context;
  }

  @override
  Set<GeneratedColumn> get $primaryKey => {id};
  @override
  Todo map(Map<String, dynamic> data, {String? tablePrefix}) {
    final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
    return Todo(
      id: attachedDatabase.typeMapping
          .read(DriftSqlType.int, data['${effectivePrefix}id'])!,
      title: attachedDatabase.typeMapping
          .read(DriftSqlType.string, data['${effectivePrefix}title'])!,
      content: attachedDatabase.typeMapping
          .read(DriftSqlType.string, data['${effectivePrefix}body'])!,
      dueDate: attachedDatabase.typeMapping
          .read(DriftSqlType.dateTime, data['${effectivePrefix}due_date']),
    );
  }

  @override
  $TodosTable createAlias(String alias) {
    return $TodosTable(attachedDatabase, alias);
  }
}

class Todo extends DataClass implements Insertable<Todo> {
  final int id;
  final String title;
  final String content;
  final DateTime? dueDate;
  const Todo(
      {required this.id,
      required this.title,
      required this.content,
      this.dueDate});
  @override
  Map<String, Expression> toColumns(bool nullToAbsent) {
    final map = <String, Expression>{};
    map['id'] = Variable<int>(id);
    map['title'] = Variable<String>(title);
    map['body'] = Variable<String>(content);
    if (!nullToAbsent || dueDate != null) {
      map['due_date'] = Variable<DateTime>(dueDate);
    }
    return map;
  }

  TodosCompanion toCompanion(bool nullToAbsent) {
    return TodosCompanion(
      id: Value(id),
      title: Value(title),
      content: Value(content),
      dueDate: dueDate == null && nullToAbsent
          ? const Value.absent()
          : Value(dueDate),
    );
  }

  factory Todo.fromJson(Map<String, dynamic> json,
      {ValueSerializer? serializer}) {
    serializer ??= driftRuntimeOptions.defaultSerializer;
    return Todo(
      id: serializer.fromJson<int>(json['id']),
      title: serializer.fromJson<String>(json['title']),
      content: serializer.fromJson<String>(json['content']),
      dueDate: serializer.fromJson<DateTime?>(json['dueDate']),
    );
  }
  @override
  Map<String, dynamic> toJson({ValueSerializer? serializer}) {
    serializer ??= driftRuntimeOptions.defaultSerializer;
    return <String, dynamic>{
      'id': serializer.toJson<int>(id),
      'title': serializer.toJson<String>(title),
      'content': serializer.toJson<String>(content),
      'dueDate': serializer.toJson<DateTime?>(dueDate),
    };
  }

  Todo copyWith(
          {int? id,
          String? title,
          String? content,
          Value<DateTime?> dueDate = const Value.absent()}) =>
      Todo(
        id: id ?? this.id,
        title: title ?? this.title,
        content: content ?? this.content,
        dueDate: dueDate.present ? dueDate.value : this.dueDate,
      );
  @override
  String toString() {
    return (StringBuffer('Todo(')
          ..write('id: $id, ')
          ..write('title: $title, ')
          ..write('content: $content, ')
          ..write('dueDate: $dueDate')
          ..write(')'))
        .toString();
  }

  @override
  int get hashCode => Object.hash(id, title, content, dueDate);
  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      (other is Todo &&
          other.id == this.id &&
          other.title == this.title &&
          other.content == this.content &&
          other.dueDate == this.dueDate);
}

class TodosCompanion extends UpdateCompanion<Todo> {
  final Value<int> id;
  final Value<String> title;
  final Value<String> content;
  final Value<DateTime?> dueDate;
  const TodosCompanion({
    this.id = const Value.absent(),
    this.title = const Value.absent(),
    this.content = const Value.absent(),
    this.dueDate = const Value.absent(),
  });
  TodosCompanion.insert({
    this.id = const Value.absent(),
    required String title,
    required String content,
    this.dueDate = const Value.absent(),
  })  : title = Value(title),
        content = Value(content);
  static Insertable<Todo> custom({
    Expression<int>? id,
    Expression<String>? title,
    Expression<String>? content,
    Expression<DateTime>? dueDate,
  }) {
    return RawValuesInsertable({
      if (id != null) 'id': id,
      if (title != null) 'title': title,
      if (content != null) 'body': content,
      if (dueDate != null) 'due_date': dueDate,
    });
  }

  TodosCompanion copyWith(
      {Value<int>? id,
      Value<String>? title,
      Value<String>? content,
      Value<DateTime?>? dueDate}) {
    return TodosCompanion(
      id: id ?? this.id,
      title: title ?? this.title,
      content: content ?? this.content,
      dueDate: dueDate ?? this.dueDate,
    );
  }

  @override
  Map<String, Expression> toColumns(bool nullToAbsent) {
    final map = <String, Expression>{};
    if (id.present) {
      map['id'] = Variable<int>(id.value);
    }
    if (title.present) {
      map['title'] = Variable<String>(title.value);
    }
    if (content.present) {
      map['body'] = Variable<String>(content.value);
    }
    if (dueDate.present) {
      map['due_date'] = Variable<DateTime>(dueDate.value);
    }
    return map;
  }

  @override
  String toString() {
    return (StringBuffer('TodosCompanion(')
          ..write('id: $id, ')
          ..write('title: $title, ')
          ..write('content: $content, ')
          ..write('dueDate: $dueDate')
          ..write(')'))
        .toString();
  }
}

abstract class _$AppDatabase extends GeneratedDatabase {
  _$AppDatabase(QueryExecutor e) : super(e);
  late final $TodosTable todos = $TodosTable(this);
  late final TodoDao todoDao = TodoDao(this as AppDatabase);
  @override
  Iterable<TableInfo<Table, Object?>> get allTables =>
      allSchemaEntities.whereType<TableInfo<Table, Object?>>();
  @override
  List<DatabaseSchemaEntity> get allSchemaEntities => [todos];
}
```

:::note info
データベースのテーブル定義とデータアクセスロジックを一貫して扱うための基盤を提供します。アプリケーション開発者は、これらのクラスやメソッドを使うことで、SQLを直接書くことなくデータベース操作を行うことができます。
:::

```dart:lib/infra/repository/todo_dao.g.dart
// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'todo_dao.dart';

// ignore_for_file: type=lint
mixin _$TodoDaoMixin on DatabaseAccessor<AppDatabase> {
  $TodosTable get todos => attachedDatabase.todos;
}
```

:::note info
このmixinは、TodoDaoクラスにtodosテーブルへのアクセスを追加する役割を持っています。これにより、TodoDao内でtodosテーブルに対するクエリや操作を簡単に行えるようになります。自動生成されたコードなので、開発者はこれを直接編集する必要はなく、DAOクラス内でテーブル操作が簡潔にできるようサポートしてくれるものです。
:::

## 動作検証
では実際にアプリケーションから実行できるか確認していきましょう。
![drift_flutter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599049/4dda3b07-2e19-f915-0eac-0d9ffc31e40a.gif)

```dart:lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'infra/repository/app_database.dart';
import 'screen/todo_screen.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Provider(
      create: (_) => AppDatabase(),
      dispose: (_, AppDatabase db) => db.close(),
      child: MaterialApp(
        title: 'Drift Todo Example',
        home: TodoScreen(),
      ),
    );
  }
}
```

```dart:lib/screen/todo_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../infra/repository/todo_dao.dart';
import '../infra/repository/app_database.dart';
import 'package:drift/drift.dart' as drift;

class TodoScreen extends StatefulWidget {
  @override
  _TodoScreenState createState() => _TodoScreenState();
}

class _TodoScreenState extends State<TodoScreen> {
  final TextEditingController _titleController = TextEditingController();
  final TextEditingController _contentController = TextEditingController();
  TodoDao? _todoDao;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final database = Provider.of<AppDatabase>(context);
    _todoDao = database.todoDao;
  }

  void _insertTodo() async {
    final newTodo = TodosCompanion(
      title: drift.Value(_titleController.text),
      content: drift.Value(_contentController.text),
    );
    await _todoDao?.insertTodo(newTodo);
    _titleController.clear();
    _contentController.clear();
    setState(() {});
  }

  void _deleteTodo(Todo todo) async {
    await _todoDao?.deleteTodo(todo);
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Todo List'),
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              children: [
                TextField(
                  controller: _titleController,
                  decoration: InputDecoration(labelText: 'Title'),
                ),
                TextField(
                  controller: _contentController,
                  decoration: InputDecoration(labelText: 'Content'),
                ),
                ElevatedButton(
                  onPressed: _insertTodo,
                  child: Text('Add Todo'),
                ),
              ],
            ),
          ),
          Expanded(
            child: StreamBuilder<List<Todo>>(
              stream: _todoDao?.watchAllTodos(),
              builder: (context, snapshot) {
                if (!snapshot.hasData) {
                  return Center(child: CircularProgressIndicator());
                }
                final todos = snapshot.data!;
                return ListView.builder(
                  itemCount: todos.length,
                  itemBuilder: (context, index) {
                    final todo = todos[index];
                    return ListTile(
                      title: Text(todo.title),
                      subtitle: Text(todo.content),
                      trailing: IconButton(
                        icon: Icon(Icons.delete),
                        onPressed: () => _deleteTodo(todo),
                      ),
                    );
                  },
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}
```