狐好きぷろぐらまー

狐好きプログラマーのブログです。

【Drift】Driftの個人的なTipsをまとめてみた【Flutter】

こんにちは。pregum_foxです。

今回はdriftの使い方についていくつか自分が調べて情報がなかった箇所についてtipsとして書いていこうと思います。

以下目次です。

検証環境

fvm flutter doctor -v
[✓] Flutter (Channel stable, 3.22.1, on macOS 14.3.1 23D60 darwin-arm64, locale ja-JP)
    • Flutter version 3.22.1 on channel stable at /Users/pregum/fvm/versions/3.22.1
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision a14f74ff3a (4 weeks ago), 2024-05-22 11:08:21 -0500
    • Engine revision 55eae6864b
    • Dart version 3.4.1
    • DevTools version 2.34.3

サンプルで作成したリポジトリ

今回の検証は以下のリポジトリにて試したものがあるので、よければ参考にしてください

https://github.com/Pregum/drift_practice

set up

driftのセットアップは、下記のページからテーブルの定義方法から実際にdbへCRUD処理を行うところまで書かれております。

まずはこちらのページを読んでイメージを掴んでみてください

https://drift.simonbinder.eu/docs/getting-started/

上記をベースに、もう少し込み入ったことをするときのtipsを書いていきます。

primaryKeyをuuidにする方法

drift(sqlite) では uuidカラムは存在しないのですが、clientDefault を使うことで、生成時に関数を実行してuuidを生成できるようになります

const _uuid = Uuid();

class TodoCategories extends Table {
  TextColumn get id => text().clientDefault(() => _uuid.v4())(); // これで、レコード追加時にuuidが入るようになる
  TextColumn get description => text()();

  @override
  Set<Column<Object>>? get primaryKey => {id};
}

似たようなメソッドでwithDefault()がありますが、こちらは生成時に関数は実行されず、同じ値が入る為、デフォルト値として空文字を入れたりする際に使用します。

withDefault についての説明は下記をご覧ください

Dart tables

外部キーの有効化

drift(sqlite) では、外部キー設定はデフォルトでOFFになっている為、まずはdbアクセス前に外部キー設定をONにします。

  /// 重要なお知らせ: sqlite3 では、外部キーはデフォルトで有効になっていません。外部キーを使用する場合は、[MigrationStrategy.beforeOpen] コールバックでオプションを有効にすることを忘れないでください:
  /// 外部キーを有効にする為にPRAGMA foreign_keys = ONを実行
  @override
  MigrationStrategy get migration => MigrationStrategy(
        onCreate: (detail) {
          return detail.createAll();
        },
        //毎回アプリを明けデータベースにアクセスするタイミングで呼ばれる。
        beforeOpen: (details) async {
          await customStatement('PRAGMA foreign_keys = ON;');
        },
      );

外部キー制約の定義方法(CASCADE / RESTRICT 等)

references を使うことで外部キーの設定ができます。 第1引数に参照先のテーブル、第2引数に一致させるカラムに# をつけたシンボル型で定義します。

class TodoItemHashTag extends Table {
  IntColumn get todoItemId =>
      integer().references(TodoItems, #id, onDelete: KeyAction.cascade)();
  TextColumn get hashTagId =>
      text().references(HashTags, #id, onDelete: KeyAction.cascade)();
  // こんなかんじで外部キーを指定することもできる
  // TextColumn get hashTagId =>
  //     text().customConstraint('REFERENCES hash_tags(id)')();

  @override
  Set<Column<Object>>? get primaryKey => {todoItemId, hashTagId};
}

上記のコードに書いている通り、onDeleteの箇所で外部キー制約が設定できます。 更新時は、onUpdateで定義できます 上記の例だと、todoItemId もしくは hashTagId が一致するレコードが削除された時、同じidが含まれる TodoItemHashTag のテーブル

中間テーブルを含むDB操作

シンプルなDB操作の場合、後述するmanagersを使用することでIterableな配列と似たインターフェースでDB操作が可能です ですが、中間テーブルが必要な3つ以上のテーブルを操作する場合、DAO(Data Access Object)を使って操作を行います。 おそらくみなさん〜〜Serviceや〜〜Repositoryといった名前で普段定義されているクラスのことだと思ってもらえれば良いです

下記に HashTag に紐づく TodoItem のリストを返すメソッドです。 少し長いですが、以下の中央付近のコードで紐付けを行っています。

  Future<(HashTag, List<TodoItem>)> getHashTagWithItems({
    required String hashTagId,
  }) async {
    // 対象のハッシュタグを取得
    final hashTag = await (select(hashTags)
          ..where((c) => c.id.equals(hashTagId)))
        .getSingle();

    // 紐づくTodoItemsを中間テーブル経由で取得
    final relatedTodoItemsQuery = select(hashTags).join([
      innerJoin(
          todoItemHashTag, todoItemHashTag.hashTagId.equalsExp(hashTags.id)),
      innerJoin(todoItems, todoItems.id.equalsExp(todoItemHashTag.todoItemId)),
    ])
      ..where(hashTags.id.equals(hashTagId));
    final result = await relatedTodoItemsQuery.get();
    final todoList = result.map((row) {
      final item = row.readTable(todoItems);
      return item;
    }).toList();

    return (hashTag, todoList);
  }

マイグレーション方法

まだそこまで試せれてはいないのですが、schemaVersion バージョンを上げていき、その際のマイグレーションmigration getterで記述していけば良さそうです。 カラム名の変更などもできるのでうまくマイグレーション毎に処理を切り出すのが大切になりそう

https://drift.simonbinder.eu/docs/migrations/

serializer(DataTimeをISO8601形式にする)

今回サンプルレポジトリ内には入れていないのですが、toJsonを叩くとデフォルトだとunix timestampが設定されています。

ビルドオプション設定を変えることで、ISO8601 に変えることもできるらしいのですが、今回はコードで変更する方法を選択しました。

下記のようにtoJsonのSerializerをdefaultファクトリメソッドで呼び出して引数にiso8601の使用フラグがあるのでそれをtrueにすればOKです。

final exhibitionData = await query.getSingle();
return Exhibition.fromJson(
  exhibitionData.toJson(
    serializer: const ValueSerializer.defaults(
        serializeDateTimeValuesAsString: true),
  ),
);

オプションの詳細な説明は下記をご覧ください。

https://drift.simonbinder.eu/docs/getting-started/advanced_dart_tables/#datetime-options

toJsonで出力されるMapのkeyをキャメルケースからスネークケースに変える

JsonSerializableと同様な形で @JsonKey を使用することができます。

ただ、 @JsonSerializable(fieldRename: FieldRename.snake) は適用できる場所がなかった為、1つずつJsonKeyを定義する力技でとりあえず対応しました...

class UserTable extends Table {
  TextColumn get id => text().clientDefault(() => _uuid.v4())();
  TextColumn get username => text().nullable()();
  @JsonKey('created_at')
  DateTimeColumn get createdAt =>
      dateTime().clientDefault(() => DateTime.now())();
  @JsonKey('updated_at')
  DateTimeColumn get updatedAt => dateTime().nullable()();

  @override
  Set<Column<Object>>? get primaryKey => {id};
}

試せてはいないのですが、ビルドオプションでsqlのテーブルのカラムを使用するオプション(use_sql_column_name_as_json_key) をtrueにすることで、デフォルトだとスネークケース(case_from_dart_to_sql オプション のデフォルトがsnake_case)で生成される為 おそらくuse_sql_column_name_as_json_key をtrueにすることで、toJsonのkeyをsnake_caseにすることができそうです。

マイグレーションなどでカラム名を変えた場合、不一致になる可能性があるので変更するときは慎重に試すのが良さそうです。

Generation options

github.com

github.com

drift_db_viewerの使い方

現在のDBの中身をbreak pointなどで毎回確認するのは大変なので、drift_db_viewerを試してみました。

driftで自動生成されたテーブルが確認できる為、導入するための敷居はすこく低くてよかったです。

今回作成したサンプルだと以下のように使用しました。

  void _handleOnPressedFAB() {
    final db = AppDatabase.singleton();
    Navigator.of(context).push<void>(
      MaterialPageRoute<void>(
        builder: (context) {
          return DriftDbViewer(db);
        },
      ),
    );
  }

シンプルなCRUD操作で使用できるmanagers

最初はChatGPTに聞いていたのですが、ドキュメントを見るとmanagersクラスがあって、そこで簡単なDB操作ができましたのでここに記載します。

CREATE

Future<void> createTodoItem() async {
  // Create a new item
  await managers.todoItems
      .create((o) => o(title: 'Title', content: 'Content'));

  // We can also use `mode` and `onConflict` parameters, just
  // like in the `[InsertStatement.insert]` method on the table
  await managers.todoItems.create(
      (o) => o(title: 'Title', content: 'New Content'),
      mode: InsertMode.replace);

  // We can also create multiple items at once
  await managers.todoItems.bulkCreate(
    (o) => [
      o(title: 'Title 1', content: 'Content 1'),
      o(title: 'Title 2', content: 'Content 2'),
    ],
  );
}

READ

通常の選択

Future<void> selectTodoItems() async {
  // Get all items
  managers.todoItems.get();

  // A stream of all the todo items, updated in real-time
  managers.todoItems.watch();

  // To get a single item, apply a filter and call `getSingle`
  await managers.todoItems.filter((f) => f.id(1)).getSingle();
}

フィルタする場合

Future<void> filterTodoItems() async {
  // All items with a title of "Title"
  managers.todoItems.filter((f) => f.title("Title"));

  // All items with a title of "Title" and content of "Content"
  managers.todoItems.filter((f) => f.title("Title") & f.content("Content"));

  // All items with a title of "Title" or content that is not null
  managers.todoItems.filter((f) => f.title("Title") | f.content.not.isNull());
}

UPDATE

Future<void> updateTodoItems() async {
  // Update all items
  await managers.todoItems.update((o) => o(content: Value('New Content')));

  // Update multiple items
  await managers.todoItems
      .filter((f) => f.id.isIn([1, 2, 3]))
      .update((o) => o(content: Value('New Content')));
}

DELETE

Future<void> deleteTodoItems() async {
  // Delete all items
  await managers.todoItems.delete();

  // Delete a single item
  await managers.todoItems.filter((f) => f.id(5)).delete();
}

他にも親テーブルのレコードの値によってフィルタ、子テーブルのレコードの値によってフィルタすることもできます。

詳細は下記URLをご覧ください

https://drift.simonbinder.eu/docs/manager/

postgreSQL のクライアントとしてDriftを使用する

実際には試していませんが、postgreSQL のDB操作もサポートされているようなので、試したい場合は下記ページが参考になるかと思います。

https://drift.simonbinder.eu/docs/platforms/postgres/

まとめ

driftという単語は知っていましたが、sqfliteしか使ったことがなかったので、コードが補完されつつ、簡易的なviewerも手軽に試せて良いなと思いました。

トランザクションも含まれて実装はできたのですが、まだ動作確認はできていないので、確認できたら追記しようと思います。

ここまで読んでいただきありがとうございました。