Hello. I'm Masaru Hirose.
I am constantly updating Masamune, an integration framework that runs on Flutter.
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
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.
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.
Choose whether to create a new Cloud SQL instance or use an existing one.
Decide on the Location, instance ID, and database name.
Decide on the Data Connect service name.
Data Connect is now available.
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.
Tapping Create user data
will create user data.
If you check the data from the DataConnect console, you can see that the data has been entered.
You can see that when you select 1
in the dropdown, the age
changes to 1
.
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.
I'll create a category.
I will create a post from the category I made.
You can see that the user is linked and displayed.
The Firestore data looks like this:
Let's try changing the title
.
Without touching anything in the app, you can see that the title of PostData has changed to "Changed Title
" as shown below.
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!
GitHub Sponsors
Sponsors are always welcome. Thank you for your support!