2024-10-23

[Flutter] Masamune framework supports FirebaseDataConnect

Hello. I'm Masaru Hirose.

I am constantly updating Masamune, an integration framework that runs on Flutter.

Hello. My name is Masaru Hirose.
https://medium.comhttps://medium.com
title

This time, I'd like to introduce a newly added package that allows you to use Firebase Data Connect, which enables the use of PostgreSQL on Firebase as announced at Google I/O 2024, from within the Masamune framework.

masamune_model_firebase_data_connect

Automatically generates GraphQL files for Firebase Data Connect from Collection and Document data schemes so that they can be used in applications.
https://pub.devhttps://pub.dev
title
Builder package to automatically generate GraphQL code for Firebase Data Connect.
https://pub.devhttps://pub.dev
title

Introduction

The Masamune framework is a framework on Flutter that uses "CLI code generation" and "automatic code generation with build_runner" to reduce the amount of code as much as possible and increase the stability and speed of app development.

Regarding databases, it also utilizes a NoSQL database based on Firestore, providing a mechanism that allows switching between runtime DB, device local DB, and Firestore with just a single line update.

Google I/O 2024 announced Firebase Data Connect, which enables the use of PostgreSQL on Firebase, and it became available as a public preview in October 2024, making it usable in Flutter as well.

In line with this, the Masamune framework has added a package that allows easy use of Firebase Data Connect, which I'd like to introduce.

You can define schemas with the following definition, just like when using Firestore or local device DB:

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

In a previous article, I wrote that ModelAdapter can now be configured for each data model.

Hello. I’m Masaru Hirose.
https://medium.comhttps://medium.com
title

By utilizing this, it becomes possible to link FirebaseDataConnect and Firestore.

Let's actually do it!

Now, let's proceed with the actual implementation.

This time, I will create a demo app where I define products and categories for an e-commerce shop in a spreadsheet, allow purchases, and keep a record of the purchase history.

Creating a Flutter project

First, create a Flutter project using katana create.

katana create net.mathru.dataconnecttest

After waiting for a short while, the project files will be created.

Creating a Firebase project

Let's create a Firebase project.

I will create the project following the method described in the previous article.

Enabling Firebase Data Connect

After creating the Firebase project, I will proceed to enable Firebase Data Connect.

Open Data Connect.

image block

Choose whether to create a new Cloud SQL instance or use an existing one.

image block

Decide on the Location, instance ID, and database name.

image block

Decide on the Data Connect service name.

image block

Data Connect is now available.

image block

Initial Firebase Setup for Flutter Project

Add the Firebase project ID and the settings to enable Firebase Data Connect in the following section of katana.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

Apply the changes in katana.yaml using the katana apply command.

katana apply

This completes the preparation.

Creating Data Models

Let's continue creating data models.

This time, I'll create message (post) data, category data, and user data.

First, let's create the user data using the following command.

katana code collection user

lib/models/user.dart will be created, so let's add the @firebaseDataConnect annotation to the class.

Also, assign table access permissions to permission in @CollectionModelPath.

I'll discuss permissions later. For now, let's set it so that all users can read/write.

Finally, let's define each field.

// 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(),
  ],
)
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();
}

Next, create the category data using the following command.

katana code collection category

lib/models/category.dart will be created, so write the same description as the user data and define each field.

// 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(),
  ],
)
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();
}

Finally, create the message (post) data using the following command.

katana code collection post

lib/models/post.dart will be created, so write the same description as the user data and define each field.

I'll define the fields, but for the message (post) data, I'll link the user data as the creator and the category data as the post category, each as a reference.

// 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(),
  ],
)
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();
}

Generate the code for the models using the following command.

katana code generate

This will generate the GraphQL schema definition for DataConnect, the contents of the FirebaseDataConnectModelAdapter, and the Dart-side code for DataConnect all at once.

Deploying the Created Schema

The automatically generated schema needs to be deployed on the server. In that case, execute the following command:

katana deploy

During deployment, there may be situations where migration is necessary. In that case, enter the firebase folder and execute the following command:

# firebase
firebase dataconnect:sql:migrate

Now you can freely handle each data model.

Creating a Page to Handle Data

Let's create a page to handle the defined models.

Edit lib/pages/home.dart as shown below to enable data creation and editing.

// 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);
            },
          ),
        ],
      ),
    );
  }
}

Changing the Default ModelAdapter

Change the default ModelAdapter to the newly auto-generated FirebaseDataConnectModelAdapter.

Open lib/adapter.dart and edit as follows:


/// 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();

This completes the implementation.

Running the App

Let's run the app and check it out.

image block

Tapping Create user data will create user data.

image block

If you check the data from the DataConnect console, you can see that the data has been entered.

image block

You can see that when you select 1 in the dropdown, the age changes to 1.

image block

Integration with Firestore

Now let's try integrating Firestore with Firebase Data Connect.

I'll link the UserModel created on Firebase Data Connect to the PostModel, and make it possible to constantly update the PostModel using Firestore's real-time updates.

Enabling Firestore

First, let's enable Firestore.

I'll enable Firestore using the method described in this previous article.

Initial Firestore setup in Flutter project

Add the following Firestore enablement settings to the relevant section of katana.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 

Apply katana.yaml with katana apply.

katana apply

Changing the ModelAdapter

I will set the FirebaseDataConnectModelAdapter for UserModel and set the ListenableFirestoreModelAdapter, which allows real-time updates, as the base ModelAdapter.

First, open lib/models/user.dart and set firebaseDataConnectModelAdapter to the adapter of CollectionModelPath.

// 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
)
class UserModel with _$UserModel {
  const factory UserModel({
    // TODO: Set the data schema.
    @Default("Guest") String name,
    int? age,
  }) = _UserModel;
  const UserModel._();

Furthermore, please open lib/adapter.dart and edit it as follows.


/// 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();

Finally, I will perform automatic code generation to reflect the changes to the CollectionModelPath of UserModel.

katana code generate

This completes the implementation.

Running the App

Let's run the app and check it out.

image block

I'll create a category.

image block

I will create a post from the category I made.

image block

You can see that the user is linked and displayed.

The Firestore data looks like this:

image block

Let's try changing the title.

image block

Without touching anything in the app, you can see that the title of PostData has changed to "Changed Title" as shown below.

image block

In this way, we were able to handle Firestore data while linking it to Firebase Data Connect data.

Conclusion

Thanks to the arrival of DataConnect for Firebase, we can now easily work with RDBs within Firebase as well.

By using the Masamune framework, we can make it even easier to handle and integrate with Firestore and other services.

I made it for my own use, but if you think it fits your implementation philosophy, by all means, use it!

Also, I releasing the source here, so issues and PullRequests are welcome!

If you have any further work requests, please contact me directly through my Twitter or website!

Offers app development and apps using Flutter and Unity. Includes information on music and videos created by the company. Distribution of images and video materials. We also accept orders for work.
https://mathru.nethttps://mathru.net
title

GitHub Sponsors

Sponsors are always welcome. Thank you for your support!

Developed the katana/masamune framework, which has dramatically improved the overall efficiency of Flutter-based application development.
https://github.comhttps://github.com
title
[Album] sing a song

Works mathru Music Vocaloid Album Len Kagamine

◀︎ Next
[Information] The album "sing a song" will be available on subscriptions.

Blog mathru Music Album Len Kagamine

▶︎ Previous