Flutterで使えるデータベースライブラリのMoorが便利すぎてやばかったので使い方を調べてみました。
MoorはSQLiteを使用するデータ永続化用のライブラリで、AndroidのRoomのような型安全でシンプルなAPIで高機能なORMマッピング機能を提供します。Mapのような汎用型ではなくテーブルをモデル化したデータクラスを使ってデータベースの読み書きができるので、カラム名のスペルミスなどをコンパイル時に検出できるためミスが少なくなります。
そしてStream
やFuture
といった標準的な非同期処理APIを使ってデータの取得や変更の監視が行えるので、FutureBuilder
やStreamBuilder
を使うことでデータベースの値を表示するUIを簡単に構築することができます。
前提
Flutterでアプリケーションを開発したことがある人。
インストール
pubspec.yamlに以下のように依存関係を宣言します。(2020年1月2日時点の最新バージョンです)
dependencies:
moor: ^2.2.0
moor_ffi: ^0.3.1
path_provider: ^1.5.1
path: ^1.6.4
dev_dependencies:
moor_generator: ^2.2.0
build_runner: ^1.7.2
テーブル定義
Todos、Categoriesというテーブルを持つデータベースを定義するファイルは以下のようになります。これをdb.dart
という名前で保存します。
import 'package:moor/moor.dart';
part 'db.g.dart';
class Todos extends Table {
IntColumn get id => integer().autoIncrement()(); // 自動採番id
TextColumn get title => text().withLength(min: 6, max: 32)(); // 制約をつける
TextColumn get content => text().named('body')(); // カラムに別名をつける
IntColumn get category => integer().nullable()(); // nullableにする(デフォルトではnull不可)
}
@DataClassName("Category")
class Categories extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get description => text()();
}
@UseMoor(tables: [Todos, Categories])
class MyDatabase {
}
ここまで書けたら一旦次のコマンドを実行します。
flutter packages pub run build_runner build
すると、db.g.dart
というファイルが生成されます。この中にTodo
,Category
といったレコード単位のデータを表すクラスや、データベース操作のためのヘルパークラスなどが含まれています。手で書くと数百行にもなるプログラムを自動で生成してくれるわけですから大したものです。
db.g.dart
ファイルができたら、先程のdb.dart
をもう少し書き足します。
import 'dart:io';
import 'package:moor/moor.dart';
import 'package:moor_ffi/moor_ffi.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
// --- ここから
part 'db.g.dart';
class Todos extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get title => text().withLength(min: 6, max: 32)();
TextColumn get content => text().named('body')();
IntColumn get category => integer().nullable()();
}
@DataClassName("Category")
class Categories extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get description => text()();
}
// --- ここまでは一緒
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'db.sqlite'));
return VmDatabase(file);
});
}
@UseMoor(tables: [Todos, Categories])
class MyDatabase extends _$MyDatabase {
MyDatabase() : super(_openConnection());
@override
int get schemaVersion => 1;
}
MyDatabase
を_$MyDatabase
というクラスを継承するように書き換えました。このクラスはdb.g.dart
の中で定義されており、データベース関連の面倒なあらゆる処理を行ってくれるやつです。_openConnection()
ではデータベースファイルのパスを指定してSqliteデータベースをどこに作成するのかを決めています。
ファイルに永続化せずインメモリで処理するデータベースを使いたい場合はreturn VmDatabase.memory()
とすることも可能です。その場合はgetApplicationDocumentsDirectory
とかを呼ぶ必要はないのでpath_provider
やpath
の依存関係は必要なくなります。
データベースを使用する
ここまでできたらMyDatabase
を適当なタイミングでインスタンス化してselect
やupdate
などのメソッドを呼ぶことでデータベース操作を行うことが可能ですが、これらのメソッドは@protected
アノテーションが付いているため継承したクラスからのみ使用することを意図して作られています。なので、ラッパーメソッドを用意します。
たとえば以下のような感じで
@UseMoor(tables: [Todos, Categories])
class MyDatabase extends _$MyDatabase {
// schemaVersion とコンストラクタは省略
/// 全てのTodoを取得する
Future<List<Todo>> get allTodoEntries => select(todos).get();
/// Categoryに紐付く全てのTodoを取得する。Streamはデータベースの変更を監視し、変更が行われたら新しい結果を返す。
Stream<List<Todo>> watchEntriesInCategory(Category c) {
return (select(todos)..where((t) => t.category.equals(c.id))).watch();
}
/// [id]のTodoを取得する。Streamはデータベースの変更を監視し、変更が行われたら新しい結果を返す。
Stream<Todo> todoById(int id) {
return (select(todos)..where((t) => t.id.equals(id))).watchSingle();
}
}
上の中に現れるtodos
というプロパティは親クラス_$MyDatabase
にあるもので、Moorが自動生成します。select(todos)
という記述はSELECT * FROM todos
というSQLに対応します。
select()
メソッドはQuery
クラスを返します。このQuery
のget()
を呼ぶと結果がFuture
で返ります。Query
のwatch()
を呼ぶとStream
を取得することができます。このStream
は、最初に一度結果を返した後、データベースに変更が行われたタイミングで更新されたデータを送ってきます。StreamBuilder
と組み合わせると便利です。
Create
テーブルにデータを追加する(INSERT文)には、以下のようなメソッドを用意します。
/// 生成されたidを返す
Future<int> addTodoEntry(TodosCompanion entry) {
return into(todos).insert(entry);
}
TodosCompanion
というクラスが登場しました。これはdb.g.dart
の中に生成されたクラスです。
Todo
クラスも生成されているのに、なぜTodosCompanion
が必要なのでしょう?それはTodo
は実際のテーブル内の1行を表すクラスであるからです。
テーブルにデータを追加するときは、今回の例のように自動採番(autoincrement)なidを使ったり、テーブルで指定したデフォルト値を使用したりすることがあるため、必ずしも全てのカラムを指定するわけではありません。Todo
クラスを使うと、そのカラムが省略されているのかnullが指定されているのかを判別することができません。「カラムを省略して追加する」という概念を表すためにTodosCompanion
が必要なのです。
上のメソッドは、以下のようにして使います。
addTodoEntry(
TodosCompanion(
title: Value('Important task'),
content: Value('Refactor persistence code'),
),
);
Read
データを読み取るには、先に紹介した例のようにselect()
メソッドを使います。
Future<List<Todo>> getAllTodoEntries() {
return select(todos).get();
}
Update
insertのときと同じように、update時にもTodosCompanion
を使用します。理由は同じくupdate時にも一部のカラムだけを更新することがあるためです。
/// 変更された行数を返す
Future<int> updateTodo(int id, TodosCompanion todo) {
return (update(todos)..where((it) => it.id.equals(id))).write(todo);
}
Delete
deleteする時はdelete
メソッドを使用します。
/// 変更された行数を返す
Future<int> deleteTodo(int id) {
return (delete(todos)..where((it) => it.id.equals(id))).go();
}
ここまでできれば、基本的なデータ操作はバッチリですね!
その他いろいろ
公式のドキュメントには入門方法しか記載されておらず、ちょっと凝ったことをやろうとするとどうすればいいの?って感じでした。GitHubのissueとか見ながらあれこれ調べたものです。
プライマリーキーを設定する
チュートリアルにあるようなIntColumn get id => integer().autoIncrement()();
は自動採番のプライマリーキーを作りますが、明示的にプライマリーキーを指定したい場合や複合プライマリーキーを使用したい場合は、テーブルクラスでprimaryKey
をオーバーライドして、プライマリーキーとなるカラムのセットを返すようにします。
class MyData extends Table {
TextColumn get name => text()();
TextColumn get description => text()();
@override
Set<Column> get primaryKey => {name};
}
countクエリを書く
@UseMoor
アノテーションにqueries
を指定すると、データベースクラスにカスタムクエリの結果を返すメソッドを自動生成することができます。
2020/03/23追記: queries
の指定はlegacy扱いの機能らしいので、.moorファイルを使う方法に移行した方が良さそうです。使い方はまだ調べてないのでそのうち…
@UseMoor(
tables: [MyData],
queries: {
'_countMyData': 'SELECT COUNT(*) FROM myData',
},
)
class MyDatabase extends _$MyDatabase {
Future<int> get count => _countMyData().then((result) => result.first);
}
上の例では_countMyData
というメソッドを生成しています。生成されるメソッドはFuture<List<int>>
を返すのですが、集計クエリは常に一件の結果を返すのでラッパーメソッドを使って先頭の一件だけを返すようにしています。
より複雑な処理をするために_countMyDataQuery
というメソッドも同時に生成されます。これを使うとFuture
だけでなくStream
も返すことができます。上のコードは下のように書き換えることも可能です。
@UseMoor(
tables: [MyData],
queries: {
'_countMyData': 'SELECT COUNT(*) FROM myData',
},
)
class MyDatabase extends _$MyDatabase {
Future<int> get count => _countMyDataQuery().getSingle();
Stream<int> get watchCount => _countMyDataQuery().watchSingle();
}
コード生成に失敗した時
flutter packages pub run build_runner build
を実行したことがある(.g.dartファイルがすでに存在している)状態でもう一度これを実行すると、エラーになることがあります。そういう時は--delete-conflicting-outputs
オプションを付けて実行すると直ることがあります。
flutter packages pub run build_runner build --delete-conflicting-outputs
pubspec.yamlを編集した後にflutter packages pub run build_runner build
が失敗することもあります。そういうときは
flutter pub get
を実行します。