[Flutter] DartのclassとFirestoreデータを相互変換、そしてCRUDへ

# 2021年12月追記
* 今はfreezedを使って似たようなことをしています
* document_id と 実際のデータのラッパークラスは記事と同様に使っています

# 元記事

Flutterに限らずシステム開発をしているとデータ構造を表すEntityクラスをDBにCRUDすることは多いと思います。FlutterではFirestoreのプラグインはありますが、Dartのカスタムクラスを引数にとるCRUDのメソッドはありません。つまり一度Mapに変換する必要があるということです。

本エントリではDartのクラスとMapを相互変換してFirestoreを使いやすくする方法について紹介します。


まずはシンプルに次のような構造のユーザーデータを使用する場合のクラスを考えます。

```
{
  nickname: "Bob",
  age: 23,
  isPushEnabled: true,
  createdAt: 2018-12-27 // Firestoreの日付型
}
```

# エンティティ
## User
例に示したユーザーデータを表すUserクラスを次のように定義しています。

```dart
class User {
  String name;
  int age;
  bool isPushEnabled;
  DateTime createdAt;

  // コンストラクタ
  User();

  // コンストラクタ
  // FirestoreのMapからインスタンス化
  User.fromMap(Map<String, dynamic> map) {
    this.name = map[UserField.name];
    this.age = map[UserField.age];
    this.isPushEnabled = map[UserField.isPushEnabled];

    // DartのDateに変換
    final originCreatedAt = map[UserField.createdAt];
    if (originCreatedAt is Timestamp) {
      this.createdAt = originCreatedAt.toDate();
    }
  }

  // Firestore用のMapに変換
  Map<String, dynamic> toMap() {
    return {
      UserField.name: this.name,
      UserField.age: this.age,
      UserField.isPushEnabled: this.isPushEnabled,
      UserField.createdAt: this.createdAt, // Dateはそのまま渡せる
    };
  }
}
```

Userクラスには`User.fromMap`というFirestoreのレスポンスのMapからインスタンスを生成するためのコンストラクタを定義しています。
また逆にFirestoreに登録するときに使用するために`toMap`というインスタンスのデータをMapに変換するための関数も定義しています。
この2つの関数を定義することでFirestoreのデータとDartのクラスの相互変換を可能にしています。

## UserField
Userクラスに`UserField`というクラスが使用されていることに気づいた方もいると思います。
このクラスはFirestoreのフィールド名をタイポしないために定義したものです。

```dart
class UserField {
  static const name = "name";
  static const age = "age";
  static const isPushEnabled = "isPushEnabled";
  static const createdAt = "createdAt";
}
```

## UserDocument
リレーショナルDBの場合IDは1つのフィールドに含まれているので、Userクラスのプロパティの一つとして組み込みやすいです。
Firestoreの場合データ自体にはIDは含まれていないので、同じUserクラスにまとめるのは少し違和感がありました。
しかしリストビューのアイテムを設定する時のように、IDとデータをセットで扱いたいことは多いです。
この問題を解決するためにIDとデータをセットにしたクラスを`UserDocument`クラスとして別途定義しています。

```dart
class UserDocument {
  String documentId;
  User user;

  UserDocument(this.documentId, this.user);
}
```

# データストア
CRUDのための機能を1箇所にまとめるために専用のクラスを以下のように作っています。

このクラスの関数に渡すのはDartのクラスであるUserクラスのインスタンスです。
このためFirestoreのデータ構造については関数の呼び出し元は特に意識しなくて良くなっています。
基本的なCRUDは`addUser` `getUser` `updateUser` `deleteUser`です。
特定のフィールドのみ更新したい場合は`updateName`のように実装します。

```dart
class UserDatastore {
  static String getCollectionPath() {
    return "users";
  }

  static String getDocumentPath(String documentId) {
    return "users/$documentId";
  }

  static String addUser(User user) {
    final newDocument =
        Firestore.instance.collection(getCollectionPath()).document();
    newDocument.setData(user.toMap());
    return newDocument.documentID;
  }

  static Future<User> getUser(String documentId) async {
    final snapshot =
        await Firestore.instance.document(getDocumentPath(documentId)).get();
    if (snapshot.exists) {
      return User.fromMap(snapshot.data);
    } else {
      throw Error();
    }
  }

  static void deleteUser(String documentId) {
    Firestore.instance.document(getDocumentPath(documentId)).delete();
  }

  static void updateUser(String documentId, User user) {
    Firestore.instance
        .document(getDocumentPath(documentId))
        .setData(user.toMap());
  }

  static void updateName(String documentId, String name) {
    Firestore.instance
        .document(getDocumentPath(documentId))
        .updateData({UserField.name: name});
  }
}
```

Listenする系の関数についてはここに定義するかはちょっと迷っています。
なぜならListenの停止や再開などをウィジェット側とうまく分離できるか分からないからです。
現状の私のプロジェクトではListenする系のリクエストはウィジェットに書いてしまっています。

#全コード
使用したコードの全文をのせておきます。

## エンティティ
```dart:entity/User.dart
import 'package:cloud_firestore/cloud_firestore.dart';

class User {
  String name;
  int age;
  bool isPushEnabled;
  DateTime createdAt;

  // コンストラクタ
  User();

  // コンストラクタ
  // FirestoreのMapからインスタンス化
  User.fromMap(Map<String, dynamic> map) {
    this.name = map[UserField.name];
    this.age = map[UserField.age];
    this.isPushEnabled = map[UserField.isPushEnabled];

    // DartのDateに変換
    final originCreatedAt = map[UserField.createdAt];
    if (originCreatedAt is Timestamp) {
      this.createdAt = originCreatedAt.toDate();
    }
  }

  // Firestore用のMapに変換
  Map<String, dynamic> toMap() {
    return {
      UserField.name: this.name,
      UserField.age: this.age,
      UserField.isPushEnabled: this.isPushEnabled,
      UserField.createdAt: this.createdAt, // Dateはそのまま渡せる
    };
  }
}

class UserField {
  static const name = "name";
  static const age = "age";
  static const isPushEnabled = "isPushEnabled";
  static const createdAt = "createdAt";
}

class UserDocument {
  String documentId;
  User user;

  UserDocument(this.documentId, this.user);
}

```
## データストア
```dart:entity/UserDatastore.dart
import '../entity/User.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'dart:async';

class UserDatastore {
  static String getCollectionPath() {
    return "users";
  }

  static String getDocumentPath(String documentId) {
    return "users/$documentId";
  }

  static String addUser(User user) {
    final newDocument =
        Firestore.instance.collection(getCollectionPath()).document();
    newDocument.setData(user.toMap());
    return newDocument.documentID;
  }

  static Future<User> getUser(String documentId) async {
    final snapshot =
        await Firestore.instance.document(getDocumentPath(documentId)).get();
    if (snapshot.exists) {
      return User.fromMap(snapshot.data);
    } else {
      throw Error();
    }
  }

  static void deleteUser(String documentId) {
    Firestore.instance.document(getDocumentPath(documentId)).delete();
  }

  static void updateUser(String documentId, User user) {
    Firestore.instance
        .document(getDocumentPath(documentId))
        .setData(user.toMap());
  }

  static void updateName(String documentId, String name) {
    Firestore.instance
        .document(getDocumentPath(documentId))
        .updateData({UserField.name: name});
  }
}

```

## エンティティとデータストアを使用するウィジェット
```dart:sample-app.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'dart:async';
import 'package:flutter/material.dart';
import './entity/User.dart';
import './datastore/UserDatastore.dart';

// 実験用
class SampleApp extends StatefulWidget {
  @override
  State createState() => SampleAppState();
}

class SampleAppState extends State<SampleApp> {
  UserDocument userDocument;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text("Flutter Firestore Example"),
        ),
        body: Column(
          children: <Widget>[
            Text(userDocument == null
                ? "No user"
                : "User Name: ${userDocument.user.name}"),
            FlatButton(
              child: Text("Create"),
              onPressed: () {
                final user = User();
                user.name = "Takashi";
                user.age = 36;
                user.isPushEnabled = false;
                user.createdAt = DateTime.now();
                final documentId = UserDatastore.addUser(user);
                print("added:${documentId}");
                setState(() {
                  userDocument = UserDocument(documentId, user);
                });
              },
            ),
            FlatButton(
              child: Text("Read"),
              onPressed: () async {
                final user =
                    await UserDatastore.getUser(userDocument.documentId);
                print("getUser success:${user.name}");
              },
            ),
            FlatButton(
              child: Text("Update"),
              onPressed: () {
                setState(() {
                  userDocument.user.name = "Jiro";
                  userDocument.user.age = 44;
                });
                UserDatastore.updateUser(
                    userDocument.documentId, userDocument.user);
              },
            ),
            FlatButton(
              child: Text("Delete"),
              onPressed: () {
                UserDatastore.deleteUser(userDocument.documentId);
                setState(() {
                  userDocument = null;
                });
              },
            ),
            FlatButton(
              child: Text("Update Name"),
              onPressed: () {
                setState(() {
                  userDocument.user.name = "Bob";
                });
                UserDatastore.updateName(userDocument.documentId, "Bob");
              },
            ),
          ],
        ),
      ),
    );
  }
}
```

<img src="https://qiita-image-store.s3.amazonaws.com/0/18273/548c3adb-aeac-0502-46bb-8af25c4da5e5.png" width="400px">