【Flutter】MasamuneフレームワークがFirebaseDataConnectに対応

こんにちは。広瀬マサルです。

Flutter上で動作する統合フレームワーク**Masamune**を随時更新しています。

https://zenn.dev/mathru/articles/dcd9a52bcf3e94

今回は[GoogleI/O2024](https://developers.googleblog.com/whats-new-in-firebase-io-24/)で発表されたFirebase上でPostgreSQLを利用可能になる[FirebaseDataConnect](https://firebase.google.com/docs/data-connect)を[Masamuneフレームワーク](https://pub.dev/packages/masamune)上から利用できるパッケージを新しく追加したので紹介します。

# masamune_model_firebase_data_connect

https://pub.dev/packages/masamune_model_firebase_data_connect

https://pub.dev/packages/masamune_model_firebase_data_connect_builder

# はじめに

[Masamuneフレームワーク](https://pub.dev/packages/masamune)は「**CLIによるコード生成**」と「**build_runnnerによるコードの自動生成**」を用いて可能な限りコードの記述量を減らし、アプリ開発の安定性と高速性を高めるFlutter上のフレームワークです。

データベースに関しても**Firestore**をベースにしたNoSQLデータベースを利用し、わずか１行の更新で`ランタイムDB`と`端末ローカルDB`、`Firestore`を切り替えることが可能な仕組みを提供しています。

[GoogleI/O2024](https://developers.googleblog.com/whats-new-in-firebase-io-24/)で発表されたFirebase上でPostgreSQLを利用可能になる[FirebaseDataConnect](https://firebase.google.com/docs/data-connect)が2024年10月にパブリックプレビューとなりFlutterでも利用できるようになりました。

[Masamuneフレームワーク](https://pub.dev/packages/masamune)ではこれに合わせて[FirebaseDataConnect](https://firebase.google.com/docs/data-connect)を簡単に利用できるようなパッケージを追加しましたので紹介します。

`Firestore`や`端末ローカルDB`での利用と同じ様に下記のような定義でスキーマを定義できます。

```dart
/// Value for model.
@freezed
@formValue
@immutable
@firebaseDataConnect
// TODO: Set the path for the collection.
@CollectionModelPath(
  "user",
  permission: [
    AllowReadModelPermissionQuery.allUsers(),
    AllowWriteModelPermissionQuery.allUsers(),
  ],
)
abstract class UserModel with _$UserModel {
  const factory UserModel({
    // TODO: Set the data schema.
    @Default("Guest") String name,
    int? age,
  }) = _UserModel;
  const UserModel._();
  
  ~~~~
}
```

以前下記の記事で`ModelAdapter`を**各データモデルごとに設定可能**になったと書きました。

https://zenn.dev/mathru/articles/48e5febda53454

これを利用することで`FirebaseDataConnect`と`Firestore`を連携することが可能になります。

# 実際にやってみよう

それでは実際の実装を行っていきたいと思います。

今回は簡易的にECショップの商品やカテゴリーをスプレッドシートに定義し、それを購入して履歴に残すデモアプリを作成していきます。

## Flutterプロジェクトの作成

まず`katana create`でFlutterプロジェクトを作成します。

```bash
katana create net.mathru.dataconnecttest
```

少し待てばプロジェクトファイルが作成されます。

## Firebaseのプロジェクト作成

Firebaseのプロジェクトを作成します。

以前のこの記事に記載している通りの方法でプロジェクト作成までを行います。

https://zenn.dev/mathru/books/d219c9b7cdfd53/viewer/5d6da8

## FirebaseDataConnectを有効化

Firebaseのプロジェクト作成後FirebaseDataConnectを有効化していきます。

Data Connectを開きます。

![](https://storage.googleapis.com/zenn-user-upload/4917e2cf0b1c-20241023.png)

新しくCloudSQLのインスタンスを作成しても既存のものを利用するかを選びます。

![](https://storage.googleapis.com/zenn-user-upload/ba592a5695f4-20241023.png)

LocationとインスタンスID、データベース名を決定します。

![](https://storage.googleapis.com/zenn-user-upload/106a45190c36-20241023.png)

DataConnectのサービス名を決定します。

![](https://storage.googleapis.com/zenn-user-upload/964c04de6fdb-20241023.png)

DataConnectが利用可能になります。

![](https://storage.googleapis.com/zenn-user-upload/754cc5d5c24b-20241023.png)

## FlutterプロジェクトでのFirebaseの初期設定

`katana.yaml`の下記部分のFirebaseのプロジェクトIDとFirebaseDataConnectの有効化の設定を追記します。

```yaml

# This section contains information related to Firebase.
# Firebase関連の情報を記載します。
firebase:
  # Set the Firebase project ID.
  # FirebaseのプロジェクトIDを設定します。
  project_id: masamune-test

  # Enable Firebase Firestore.
  # Firebase Firestoreを有効にします。
  firestore:
    enable: false

  # Enable Firebase Data Connect.
  # Firebase Data Connectを有効にします。
  dataconnect:
    enable: true # false -> true
```

`katana apply`でkatana.yamlを反映させます。

```bash
katana apply
```

これで準備は完了です。

## データモデルの作成

引き続きデータモデルを作成していきましょう。

今回は`メッセージ（記事）データ`、`カテゴリーデータ`、`ユーザーデータ`をそれぞれ作成していきましょう。

まず下記のコマンドで`ユーザーデータ`を作成します。

```bash
katana code collection user
```

`lib/models/user.dart`が作成されるので`@firebaseDataConnect`アノテーションをクラスに付与しましょう。

また、`@CollectionModelPath`の`permission`にテーブルへのアクセス権を付与します。

アクセス権については後述します。今回は全ユーザーが読み取り/書き取り可能な状態にしておきます。

最後に各フィールドを規定します。

```dart
// lib/models/user.dart

/// Value for model.
@freezed
@formValue
@immutable
@firebaseDataConnect
// TODO: Set the path for the collection.
@CollectionModelPath(
  "user",
  permission: [
    AllowReadModelPermissionQuery.allUsers(),
    AllowWriteModelPermissionQuery.allUsers(),
  ],
)
abstract class UserModel with _$UserModel {
  const factory UserModel({
    // TODO: Set the data schema.
    @Default("Guest") String name,
    int? age,
  }) = _UserModel;
  const UserModel._();

  factory UserModel.fromJson(Map<String, Object?> json) =>
      _$UserModelFromJson(json);

  /// Query for document.
  ///
  /// ```dart
  /// appRef.model(UserModel.document(id));       // Get the document.
  /// ref.app.model(UserModel.document(id))..load();  // Load the document.
  /// ```
  static const document = _$UserModelDocumentQuery();

  /// Query for collection.
  ///
  /// ```dart
  /// appRef.model(UserModel.collection());       // Get the collection.
  /// ref.app.model(UserModel.collection())..load();  // Load the collection.
  /// ref.app.model(
  ///   UserModel.collection().data.equal(
  ///     "data",
  ///   )
  /// )..load(); // Load the collection with filter.
  /// ```
  static const collection = _$UserModelCollectionQuery();

  /// Query for form value.
  ///
  /// ```dart
  /// ref.app.form(UserModel.form(UserModel()));    // Get the form controller in app scope.
  /// ref.page.form(UserModel.form(UserModel()));    // Get the form controller in page scope.
  /// ```
  static const form = _$UserModelFormQuery();
}
```

続いて下記のコマンドで`カテゴリーデータ`を作成します。

```bash
katana code collection category
```

`lib/models/category.dart`が作成されるのでユーザーデータと同様の記述を行い各フィールドを規定します。

```dart
// lib/models/cateogry.dart

/// Value for model.
@freezed
@formValue
@immutable
@firebaseDataConnect
// TODO: Set the path for the collection.
@CollectionModelPath(
  "category",
  permission: [
    AllowReadModelPermissionQuery.allUsers(),
    AllowWriteModelPermissionQuery.allUsers(),
  ],
)
abstract class CategoryModel with _$CategoryModel {
  const factory CategoryModel({
    // TODO: Set the data schema.
    required String name,
    String? description,
  }) = _CategoryModel;
  const CategoryModel._();

  factory CategoryModel.fromJson(Map<String, Object?> json) =>
      _$CategoryModelFromJson(json);

  /// Query for document.
  ///
  /// ```dart
  /// appRef.model(CategoryModel.document(id));       // Get the document.
  /// ref.app.model(CategoryModel.document(id))..load();  // Load the document.
  /// ```
  static const document = _$CategoryModelDocumentQuery();

  /// Query for collection.
  ///
  /// ```dart
  /// appRef.model(CategoryModel.collection());       // Get the collection.
  /// ref.app.model(CategoryModel.collection())..load();  // Load the collection.
  /// ref.app.model(
  ///   CategoryModel.collection().data.equal(
  ///     "data",
  ///   )
  /// )..load(); // Load the collection with filter.
  /// ```
  static const collection = _$CategoryModelCollectionQuery();

  /// Query for form value.
  ///
  /// ```dart
  /// ref.app.form(CategoryModel.form(CategoryModel()));    // Get the form controller in app scope.
  /// ref.page.form(CategoryModel.form(CategoryModel()));    // Get the form controller in page scope.
  /// ```
  static const form = _$CategoryModelFormQuery();
}
```

最後に下記のコマンドで`メッセージ（記事）データ`を作成します。

```bash
katana code collection post
```

`lib/models/post.dart`が作成されるのでユーザーデータと同様の記述を行い各フィールドを規定します。

フィールドを規定しますが、`メッセージ（記事）データ`については作成者としての`ユーザーデータ`と記事のカテゴリーとしての`カテゴリーデータ`をそれぞれ参照として紐づけます。

```dart
// lib/models/post.dart

/// Value for model.
@freezed
@formValue
@immutable
@firebaseDataConnect
// TODO: Set the path for the collection.
@CollectionModelPath(
  "post",
  permission: [
    AllowReadModelPermissionQuery.allUsers(),
    AllowWriteModelPermissionQuery.allUsers(),
  ],
)
abstract class PostModel with _$PostModel {
  const factory PostModel({
    // TODO: Set the data schema.
    required String title,
    String? body,
    @Default(ModelTimestamp()) ModelTimestamp createdTime,
    @refParam UserModelRef user,
    @refParam CategoryModelRef category,
  }) = _PostModel;
  const PostModel._();

  factory PostModel.fromJson(Map<String, Object?> json) =>
      _$PostModelFromJson(json);

  /// Query for document.
  ///
  /// ```dart
  /// appRef.model(PostModel.document(id));       // Get the document.
  /// ref.app.model(PostModel.document(id))..load();  // Load the document.
  /// ```
  static const document = _$PostModelDocumentQuery();

  /// Query for collection.
  ///
  /// ```dart
  /// appRef.model(PostModel.collection());       // Get the collection.
  /// ref.app.model(PostModel.collection())..load();  // Load the collection.
  /// ref.app.model(
  ///   PostModel.collection().data.equal(
  ///     "data",
  ///   )
  /// )..load(); // Load the collection with filter.
  /// ```
  static const collection = _$PostModelCollectionQuery();

  /// Query for form value.
  ///
  /// ```dart
  /// ref.app.form(PostModel.form(PostModel()));    // Get the form controller in app scope.
  /// ref.page.form(PostModel.form(PostModel()));    // Get the form controller in page scope.
  /// ```
  static const form = _$PostModelFormQuery();
}
```

下記コマンドでモデル用のコードを自動生成します。

```bash
katana code generate
```

これによりDataConnectの`GraphQLスキーマ定義`や`FirebaseDataConnectModelAdapter`の中身、`DataConnectのDart側のコード`がまとめて生成されます。

## 作成されたスキーマのデプロイ

自動生成されたスキーマをサーバー上にデプロイする必要があります。その場合は下記コマンドを実行してください。

```bash
katana deploy
```

デプロイ中にマイグレーションが必要な状態になることがあります。その場合は`firebase`フォルダに入り下記コマンドを実行してください。

```bash
# firebase
firebase dataconnect:sql:migrate
```

これで自由に各データモデルを扱えるようになりました。

## データを扱うページの作成

定義したモデルを扱うためのページを作成していきましょう。

`lib/pages/home.dart`を下記のように編集して、データの作成や編集を行えるようにしましょう。

```dart
// pages/home.dart

// ignore: unused_import, unnecessary_import
import 'package:dataconnecttest/models/category.dart';
import 'package:dataconnecttest/models/post.dart';
import 'package:dataconnecttest/models/user.dart';
import 'package:flutter/material.dart';
// ignore: unused_import, unnecessary_import
import 'package:masamune/masamune.dart';
import 'package:masamune_universal_ui/masamune_universal_ui.dart';

// ignore: unused_import, unnecessary_import
import '/main.dart';

part 'home.page.dart';

@immutable
@PagePath("/")
class HomePage extends PageScopedWidget {
  const HomePage({
    super.key,
  });

  /// Used to transition to the HomePage screen.
  ///
  /// ```dart
  /// router.push(HomePage.query(parameters));    // Push page to HomePage.
  /// router.replace(HomePage.query(parameters)); // Replace page to HomePage.
  /// ```
  @pageRouteQuery
  static const query = _$HomePageQuery();

  @override
  Widget build(BuildContext context, PageRef ref) {
    // Describes the process of loading
    // and defining variables required for the page.
    const userId = "user1";
    final user = ref.app.model(UserModel.document(userId))..load();
    final posts = ref.app.model(PostModel.collection())..load();
    final categories = ref.app.model(CategoryModel.collection())..load();

    // Describes the structure of the page.
    return UniversalScaffold(
      appBar: UniversalAppBar(title: Text(l().appTitle)),
      body: UniversalListView(
        children: [
          const ListTile(
            title: Text(
              "User Data",
              style: TextStyle(fontWeight: FontWeight.bold),
            ),
          ),
          Divider(color: theme.color.outline.withOpacity(0.25)),
          if (user.value != null)
            ListTile(
              title: Text(user.value?.name ?? ""),
              subtitle: Text(user.value?.age?.toStringAsFixed(0) ?? ""),
              trailing: SizedBox(
                width: 80,
                child: FormNumField(
                  initialValue: user.value?.age,
                  onChanged: (value) async {
                    await user.save(
                      user.value?.copyWith(age: value?.toInt()),
                    );
                  },
                  picker: const FormNumFieldPicker(
                    begin: 1,
                    end: 100,
                    interval: 1,
                  ),
                ),
              ),
            )
          else
            ListTile(
              leading: const Icon(Icons.add),
              title: const Text("Create user data"),
              onTap: () async {
                await user
                    .save(
                      UserModel(
                        name: "User at ${DateTime.now().yyyyMMddHHmmss()}",
                        age: 20,
                      ),
                    )
                    .showIndicator(context);
              },
            ),
          const Divider(),
          const ListTile(
            title: Text(
              "Post Data",
              style: TextStyle(fontWeight: FontWeight.bold),
            ),
          ),
          Divider(color: theme.color.outline.withOpacity(0.25)),
          ...posts.mapListenable((post) {
            return ListTile(
              title: Text(post.value?.title ?? ""),
              subtitle: Text(
                "${post.value?.category?.value?.name ?? ""} @${post.value?.user?.value?.name ?? ""}",
              ),
            );
          }),
          Divider(color: theme.color.outline.withOpacity(0.25)),
          ...categories.map((category) {
            return ListTile(
              leading: const Icon(Icons.add),
              title: Text(category.value?.name ?? ""),
              onTap: () async {
                final newPost = posts.create();
                await newPost
                    .save(
                      PostModel(
                        title: "Post at ${DateTime.now().yyyyMMddHHmmss()}",
                        category: category,
                        user: user,
                      ),
                    )
                    .showIndicator(context);
              },
            );
          }),
          ListTile(
            leading: const Icon(Icons.add),
            title: const Text("Create category data"),
            onTap: () async {
              final category = categories.create();
              await category
                  .save(
                    CategoryModel(
                      name: "Category at ${DateTime.now().yyyyMMddHHmmss()}",
                    ),
                  )
                  .showIndicator(context);
            },
          ),
        ],
      ),
    );
  }
}

```

## デフォルトのModelAdapterの変更

デフォルトの`ModelAdapter`を新しく自動生成された`FirebaseDataConnectModelAdapter`に変更します。

`lib/adapter.dart`を開き下記を編集してください。

```dart

/// App Model.
///
/// By replacing this with another adapter, the data storage location can be changed.
// TODO: Change the database.
const modelAdapter = firebaseDataConnectModelAdapter;
// final modelAdapter = RuntimeModelAdapter();
```

これで実装完了です。

## アプリの実行

アプリを実行して確認してみましょう。

![](https://storage.googleapis.com/zenn-user-upload/90b978b826bf-20241023.png)

`Create user data`をタップするとユーザーデータが作成されます。

![](https://storage.googleapis.com/zenn-user-upload/8ec96399a996-20241023.png)

DataConnectのコンソールからデータを見てみるとデータが入っていることがわかります。

![](https://storage.googleapis.com/zenn-user-upload/6c5c064ba927-20241023.png)

ドロップダウンで`1`を選択すると`age`が`1`に変わっていることがわかります。

![](https://storage.googleapis.com/zenn-user-upload/8fe3bbb82f1b-20241023.png)

# フィルタークエリの作り方

バージョン`2.1.8`から条件を指定したクエリを作成することができるようになりました。

`CollectionModelPath`の`query`に`ModelDatabaseQueryGroup`を作成することで実現可能です。

試しに`PostModel`を改修しcreatedTimeでフィルタできるクエリを作成してみましょう。

`lib/models/post.dart`を開き下記のように編集します。

`ModelDatabaseQueryGroup`にはクエリ自体の名前とクエリの条件を記載します。

```dart
// lib/models/post.dart

/// Value for model.
@freezed
@formValue
@immutable
@firebaseDataConnect
// TODO: Set the path for the collection.
@CollectionModelPath(
  "post",
  permission: [
    AllowReadModelPermissionQuery.allUsers(),
    AllowWriteModelPermissionQuery.allUsers(),
  ],
  query: [
    ModelDatabaseQueryGroup(
      name: "createdTime",
      conditions: [
        ModelDatabaseConditionQuery.greaterThanOrEqualTo("createdTime"),
      ],
    ),
  ],
)
abstract class PostModel with _$PostModel {
  const factory PostModel({
    // TODO: Set the data schema.
    required String title,
    String? body,
    @Default(ModelTimestamp()) ModelTimestamp createdTime,
    @refParam UserModelRef user,
    @refParam CategoryModelRef category,
  }) = _PostModel;
  const PostModel._();

  factory PostModel.fromJson(Map<String, Object?> json) =>
      _$PostModelFromJson(json);

  /// Query for document.
  ///
  /// ```dart
  /// appRef.model(PostModel.document(id));       // Get the document.
  /// ref.app.model(PostModel.document(id))..load();  // Load the document.
  /// ```
  static const document = _$PostModelDocumentQuery();

  /// Query for collection.
  ///
  /// ```dart
  /// appRef.model(PostModel.collection());       // Get the collection.
  /// ref.app.model(PostModel.collection())..load();  // Load the collection.
  /// ref.app.model(
  ///   PostModel.collection().data.equal(
  ///     "data",
  ///   )
  /// )..load(); // Load the collection with filter.
  /// ```
  static const collection = _$PostModelCollectionQuery();

  /// Query for form value.
  ///
  /// ```dart
  /// ref.app.form(PostModel.form(PostModel()));    // Get the form controller in app scope.
  /// ref.page.form(PostModel.form(PostModel()));    // Get the form controller in page scope.
  /// ```
  static const form = _$PostModelFormQuery();
}
```

下記コマンドでモデル用のコードを自動生成します。

するとGraphQLにクエリが追加され、Dartコードにもクエリを実行するためのメソッドとそれに対応する`FirebaseDataConnectModelAdapter`の実装が追加されます。

```bash
katana code generate
```

それでは`pages/home.dart`を編集して追加した条件でデータを取得してみましょう。

`PostModel.collection()`から`createdTimeQuery`というメソッドが利用可能になっているのでそれを指定します。

`createdTimeGreaterThanOrEqualTo`にはこの時間からデータを表示するための閾値を指定してください。ひとまず2024年11月25日を指定します。こうすることで2024年11月25日以前のデータが表示されなくなります。

```dart
// pages/home.dart

// ignore: unused_import, unnecessary_import
import 'package:dataconnecttest/models/category.dart';
import 'package:dataconnecttest/models/post.dart';
import 'package:dataconnecttest/models/user.dart';
import 'package:flutter/material.dart';
// ignore: unused_import, unnecessary_import
import 'package:masamune/masamune.dart';
import 'package:masamune_universal_ui/masamune_universal_ui.dart';

// ignore: unused_import, unnecessary_import
import '/main.dart';

part 'home.page.dart';

@immutable
@PagePath("/")
class HomePage extends PageScopedWidget {
  const HomePage({
    super.key,
  });

  /// Used to transition to the HomePage screen.
  ///
  /// ```dart
  /// router.push(HomePage.query(parameters));    // Push page to HomePage.
  /// router.replace(HomePage.query(parameters)); // Replace page to HomePage.
  /// ```
  @pageRouteQuery
  static const query = _$HomePageQuery();

  @override
  Widget build(BuildContext context, PageRef ref) {
    // Describes the process of loading
    // and defining variables required for the page.
    const userId = "user1";
    final user = ref.app.model(UserModel.document(userId))..load();
    final posts = ref.app.model(PostModel.collection().createdTimeQuery(
      createdTimeGreaterThanOrEqualTo: const ModelTimestamp.dateTime(2024, 11, 25),
    ))
      ..load();
    final categories = ref.app.model(CategoryModel.collection())..load();

    // Describes the structure of the page.
    return UniversalScaffold(
      appBar: UniversalAppBar(title: Text(l().appTitle)),
      body: UniversalListView(
        children: [
          const ListTile(
            title: Text(
              "User Data",
              style: TextStyle(fontWeight: FontWeight.bold),
            ),
          ),
          Divider(color: theme.color.outline.withOpacity(0.25)),
          if (user.value != null)
            ListTile(
              title: Text(user.value?.name ?? ""),
              subtitle: Text(user.value?.age?.toStringAsFixed(0) ?? ""),
              trailing: SizedBox(
                width: 80,
                child: FormNumField(
                  initialValue: user.value?.age,
                  onChanged: (value) async {
                    await user.save(
                      user.value?.copyWith(age: value?.toInt()),
                    );
                  },
                  picker: const FormNumFieldPicker(
                    begin: 1,
                    end: 100,
                    interval: 1,
                  ),
                ),
              ),
            )
          else
            ListTile(
              leading: const Icon(Icons.add),
              title: const Text("Create user data"),
              onTap: () async {
                await user
                    .save(
                      UserModel(
                        name: "User at ${DateTime.now().yyyyMMddHHmmss()}",
                        age: 20,
                      ),
                    )
                    .showIndicator(context);
              },
            ),
          const Divider(),
          const ListTile(
            title: Text(
              "Post Data",
              style: TextStyle(fontWeight: FontWeight.bold),
            ),
          ),
          Divider(color: theme.color.outline.withOpacity(0.25)),
          ...posts.mapListenable((post) {
            return ListTile(
              title: Text(post.value?.title ?? ""),
              subtitle: Text(
                "${post.value?.category?.value?.name ?? ""} @${post.value?.user?.value?.name ?? ""}",
              ),
            );
          }),
          Divider(color: theme.color.outline.withOpacity(0.25)),
          ...categories.map((category) {
            return ListTile(
              leading: const Icon(Icons.add),
              title: Text(category.value?.name ?? ""),
              onTap: () async {
                final newPost = posts.create();
                await newPost
                    .save(
                      PostModel(
                        title: "Post at ${DateTime.now().yyyyMMddHHmmss()}",
                        category: category,
                        user: user,
                      ),
                    )
                    .showIndicator(context);
              },
            );
          }),
          ListTile(
            leading: const Icon(Icons.add),
            title: const Text("Create category data"),
            onTap: () async {
              final category = categories.create();
              await category
                  .save(
                    CategoryModel(
                      name: "Category at ${DateTime.now().yyyyMMddHHmmss()}",
                    ),
                  )
                  .showIndicator(context);
            },
          ),
        ],
      ),
    );
  }
}

```

`ModelDatabaseQueryGroup`の`conditions`に複数条件を指定することで複雑なクエリを実行可能です。（2024年11月27日現在はAND条件のみ）

また、`ModelDatabaseQueryGroup`を複数指定すると様々な条件を設定することができます。

# Firestoreとの連携

それではFirestoreとFirebaseDataConnectの連携を試してみましょう。

FirebaseDataConnect上で作成した`UserModel`を`PostModel`に紐づけ、Firestoreのリアルタイムアップデートで`PostModel`を常時更新できるようにしましょう。

## Firestoreを有効化

まずFirestoreを有効化しましょう。

以前のこの記事に記載している通りの方法でFirestoreの有効化を行います。

https://zenn.dev/mathru/books/d219c9b7cdfd53/viewer/5d6da8

## FlutterプロジェクトでのFirestoreの初期設定

`katana.yaml`の下記部分のFirestoreの有効化の設定を追記します。

```yaml

# This section contains information related to Firebase.
# Firebase関連の情報を記載します。
firebase:
  # Set the Firebase project ID.
  # FirebaseのプロジェクトIDを設定します。
  project_id: masamune-test

  # Enable Firebase Firestore.
  # Firebase Firestoreを有効にします。
  firestore:
    enable: true # false -> true

  # Enable Firebase Data Connect.
  # Firebase Data Connectを有効にします。
  dataconnect:
    enable: true 
```

`katana apply`でkatana.yamlを反映させます。

```bash
katana apply
```

## ModelAdapterの変更

`UserModel`に`FirebaseDataConnectModelAdapter`を設定しベースのModelAdapterにリアルタイムアップデート可能な`ListenableFirestoreModelAdapter`を設定します。

まず`lib/models/user.dart`を開き、`CollectionModelPath`の`adapter`に`firebaseDataConnectModelAdapter`を設定します。

```dart
// lib/models/user.dart

/// Value for model.
@freezed
@formValue
@immutable
@firebaseDataConnect
// TODO: Set the path for the collection.
@CollectionModelPath(
  "user",
  permission: [
    AllowReadModelPermissionQuery.allUsers(),
    AllowWriteModelPermissionQuery.allUsers(),
  ],
  adapter: firebaseDataConnectModelAdapter, // Add here
)
abstract class UserModel with _$UserModel {
  const factory UserModel({
    // TODO: Set the data schema.
    @Default("Guest") String name,
    int? age,
  }) = _UserModel;
  const UserModel._();
```

さらに`lib/adapter.dart`を開き下記を編集してください。

```dart

/// App Model.
///
/// By replacing this with another adapter, the data storage location can be changed.
// TODO: Change the database.
final modelAdapter = ListenableFirestoreModelAdapter(
  options: DefaultFirebaseOptions.currentPlatform,
);
// const modelAdapter = firebaseDataConnectModelAdapter;
// final modelAdapter = RuntimeModelAdapter();
```

最後に`UserModel`の`CollectionModelPath`への変更を反映するためにコードの自動生成を行います。

```bash
katana code generate
```

これで実装完了です。

## アプリの実行

アプリを実行して確認してみましょう。

![](https://storage.googleapis.com/zenn-user-upload/d06c6f0ef09f-20241023.png)

カテゴリーを作ります。

![](https://storage.googleapis.com/zenn-user-upload/e72af0c43381-20241023.png)

作ったカテゴリーからポストを作成します。

![](https://storage.googleapis.com/zenn-user-upload/ff9417d0181a-20241023.png)

ユーザーが紐づけられて表示されていることがわかります。

Firestoreのデータとしては下記のようになっています。

![](https://storage.googleapis.com/zenn-user-upload/faf20f31d164-20241023.png)

`title`を変えてみましょう。

![](https://storage.googleapis.com/zenn-user-upload/cf629b3d70ad-20241023.png)

アプリ内をなにも触らずとも下記のようにPostDataのタイトルが「`Changed Title`」になっていることが確認できます。

![](https://storage.googleapis.com/zenn-user-upload/a6706f29d525-20241023.png)

**このようにFirebaseDataConnectのデータに紐づけながらFirestoreのデータを扱うことができました。**

# おわりに

FirebaseにDataConnectがやってきたおかげでFirebase内でも気軽にRDBを触ることができるようになりました。

[Masamuneフレームワーク](https://pub.dev/packages/masamune)を用いることでそれをさらに扱いやすくFirestore等と連携しやすいようにすることができます。

自分で使う用途で作ったものですが実装の思想的に合ってそうならぜひぜひ使ってみてください！

また、[こちら](https://github.com/mathrunet/flutter_masamune)にソースを公開しているのでissueやPullRequestをお待ちしてます！

また仕事の依頼等ございましたら、私の[Twitter](https://twitter.com/mathru)や[Webサイト](https://mathru.net)で直接ご連絡をお願いいたします！

https://mathru.net/ja/contact

# GitHub Sponsors

スポンサーを随時募集してます。ご支援お待ちしております！

https://github.com/sponsors/mathrunet