firestore_builder 0.1.6
firestore_builder: ^0.1.6 copied to clipboard
The easiest way to integrate Firestore into a Flutter application
Firestore Builder #
Describe your Firestore database once, in a YAML file, and let firestore_builder generate the
Dart code you would otherwise write by hand: Freezed models with fromFirestore / toFirestore,
typed document ids, typed field keys, a reference service, a query service (get, add, set, update,
delete), a stream service and, if you want them, Riverpod providers. Subcollections, enums,
DateTime, Timestamp, DocumentReference and FieldValue are handled. Generation is two
commands: dart run firestore_builder, then the usual build_runner pass for Freezed.
Why use it #
- One YAML file is the whole schema. Collections, subcollections, fields, types and
nullability live in
firestore_builder_config.yaml. Change the file, run the commands, the Dart code follows. - Typed models. Every collection becomes a Freezed class: immutable, value equality,
copyWith,toJson/fromJson, plus afromFirestore(snapshot)factory and atoFirestore()method wired intowithConverter. - Typed document ids.
TeamId,UserId,TaskId: the compiler stops you from passing a user id where a team id is expected. - Typed field keys.
Team.nameFieldKey,Team.collectionKey: no string literals in your queries. - Three generated services.
FirestoreReferenceService(typed references),FirestoreQueryService(one-shot reads and writes) andFirestoreStreamService(real-time streams). Every collection and subcollection gets its methods, and parent ids are required named parameters, so you cannot forget one. - Partial updates.
updateTeam(teamId: id, name: UpdatedValue('Blue'))writes only the fields you pass. Fields declared withaccept_field_value: truealso acceptFieldValue.serverTimestamp(),FieldValue.arrayUnion(...),FieldValue.increment(...)andFieldValue.delete(). - Riverpod providers, optional. With
use_riverpod: trueyou get a document provider and a collection provider (stream and plain value) per collection, plus providers for the services. - Enums with a fallback.
unknown_enum_valuedecodes values your app does not know yet (written by a newer version, for instance) to a fallback instead of throwing. - A DevTools extension to build the configuration file visually.
- Safe to regenerate. The output folder is cleared and rebuilt on every run (configurable).
Table of contents #
- Quick start
- Configuration reference
- Generated code walkthrough
- DevTools extension
- Workflow and tips
- Limitations and FAQ
- Contributing and license
Quick start #
1. Add the dependencies #
Set up Firebase for your app by following the official guide, then add:
flutter pub add firebase_core cloud_firestore freezed_annotation json_annotation
flutter pub add dev:firestore_builder dev:build_runner dev:freezed dev:json_serializable
If you want the generated providers (use_riverpod: true), also add:
flutter pub add flutter_riverpod
The generated models use the abstract class syntax, so they work with Freezed 3 and later.
Tested with freezed ^3.0.0, flutter_riverpod ^2.6.1 and cloud_firestore ^6.8.0 (the
example app's pins).
2. Describe your database #
Create firestore_builder_config.yaml at the root of your project (next to pubspec.yaml):
firestore_builder:
project_name: my_app # must be the `name` of your pubspec.yaml
output: lib/firestore # where the code is generated, must be under lib/
use_riverpod: true # optional, default false
collections:
- collection:
collection_name: tasks
model_name: task
fields:
- name: String
- description: String?
- done: bool
3. Generate #
dart run firestore_builder
dart run build_runner build --delete-conflicting-outputs
The first command writes the .dart sources, the second one lets Freezed and json_serializable
produce the .freezed.dart and .g.dart parts.
4. Use the generated code #
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:my_app/firestore/models/task.dart';
import 'package:my_app/firestore/models/updated_value.dart';
import 'package:my_app/firestore/services/firestore_query_service.dart';
import 'package:my_app/firestore/services/firestore_reference_service.dart';
final service = FirestoreQueryService(
firestoreReferenceService: FirestoreReferenceService(
firestore: FirebaseFirestore.instance,
),
);
final id = await service.addTask(task: const Task(name: 'Write the docs', done: false));
final tasks = await service.getTasksCollection();
await service.updateTask(taskId: TaskId(id), done: const UpdatedValue(true));
await service.deleteTask(taskId: TaskId(id));
With Riverpod, the services are already provided and each collection has its providers:
class TaskList extends ConsumerWidget {
const TaskList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// List<Task>?: null until the first snapshot arrives, then always up to date.
final tasks = ref.watch(taskCollectionProvider);
if (tasks == null) {
return const CircularProgressIndicator();
}
return ListView(children: [for (final task in tasks) Text(task.name)]);
}
}
Configuration reference #
Top-level keys #
Everything sits under a single firestore_builder: key.
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
project_name |
String | yes | The name from your pubspec.yaml. Generated files import each other and your custom classes as package:<project_name>/.... |
|
output |
String | yes | Output folder, relative to the directory you run the command from (normally the project root). It must be under lib/, for example lib/firestore: generated files import each other as package:<project_name>/<output minus lib/>/.... |
|
clear_output_folder |
bool | no | true |
Delete every .dart file under output, recursively, before generating. |
use_riverpod |
bool | no | false |
Also generate Riverpod providers: a states/ file per collection and one provider per service. |
collections |
list | no | [] |
The top-level collections (see below). |
Unknown keys are ignored silently, so double check the spelling if an option seems to have no
effect. A missing firestore_builder section, project_name or output stops the generation
with an explicit error.
Collections #
Each item of collections is a map with a collection entry and, optionally, a
sub_collections entry next to it (not inside it). Subcollections use exactly the same
structure and can be nested to any depth.
collections:
- collection:
collection_name: teams # Firestore collection id
model_name: team # base name of the generated Dart classes
fields:
- name: String
sub_collections: # sibling of `collection`
- collection:
collection_name: users
model_name: user
fields:
- name: String
| Key | Type | Required | Description |
|---|---|---|---|
collection_name |
String | yes | The Firestore collection id, used as is in Firestore paths. Also drives collection level method names, recased as needed: teamsCollection(), getTeamsCollection(), teamsCollectionStream(). |
model_name |
String | yes | Base name of the generated classes, file and document level methods, recased as needed: model_name: team_member gives TeamMember, TeamMemberId, team_member.dart, getTeamMember(), teamMemberProvider. Must be unique across the whole configuration, subcollections included, because all models go to the same models/ folder. |
fields |
list | no | The document fields. A collection without fields still gets a model holding its id. |
sub_collections |
list | no | Nested collections, same structure as collections. Declared as a sibling of collection, not inside it (see the structure above). |
Fields #
A field is a one-entry map. The key is the Firestore field name, kept exactly as written. The value is either a type (short form) or a map (long form):
fields:
- user_count: int # short form: `<firestore key>: <type>`
- created_at: # long form
type: DateTime
accept_field_value: true
- team_size:
type: TeamSize
path: models/enums/team_size.dart
unknown_enum_value: small
The Dart property is the camelCase version of the key: user_count becomes userCount, and the
constant Team.userCountFieldKey holds 'user_count' for queries.
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
type |
String | yes | One of the supported types below. | |
accept_field_value |
bool | no | false |
Let the field be written with a FieldValue (see accept_field_value). |
path |
String | for custom types | Path of the Dart file declaring the class or enum, relative to lib/ (a leading lib/ is accepted and stripped). Imported as package:<project_name>/<path>. |
|
unknown_enum_value |
String | no | Enum value used when Firestore holds a value the enum does not declare (see Custom classes and enums). |
Supported types #
| YAML type | Dart type | Notes |
|---|---|---|
String |
String |
|
int |
int |
|
double |
double |
|
bool |
bool |
|
DateTime |
DateTime |
Stored as a Firestore Timestamp. The generated DateTimeConverter converts in both directions. |
Timestamp |
Timestamp (cloud_firestore) |
Passed through untouched by the generated TimestampConverter. |
DocumentReference |
DocumentReference (cloud_firestore) |
Passed through untouched by the generated DocumentReferenceConverter. |
List<T> |
List<T> |
T is any supported type, including another List, a Map or a custom class. |
Map<String, T> |
Map<String, T> |
Keys must be String. T is any supported type. |
any other name, with path |
your class or enum | See below. |
Nullability: append ? to a type, at any level: String?, List<Timestamp?>?,
Map<String, bool>?. Non-nullable fields become required constructor parameters, nullable
ones are optional.
A type that is not in this table and has no path stops the generation with
Type X is not recognized.
Custom classes and enums #
Any type name that is not built in is treated as a custom class as soon as path is given. The
generator only needs the name and the file: the field is declared with that type and the file is
imported. Serialization is left to json_serializable, which means:
- an enum is stored by its value name (or its
@JsonValue), nothing else to do; - a class must expose a
fromJson(Map<String, dynamic>)factory and atoJson()method (a Freezed class withfromJsondoes).
unknown_enum_value is emitted as @JsonKey(unknownEnumValue: TeamSize.small). Rules:
- it is only accepted on a field whose type is a custom class declared with
path, directly or inside aList; anything else fails withunknown_enum_value requires an enum type declared with path; - it is refused on
Map<String, MyEnum>values (unknown_enum_value is not supported on Map values); - the value must be one of the enum's constants. The generator does not check this, the Dart analyzer will flag the generated model if it is wrong;
- it only applies to unknown, non-null values. A missing or
nullvalue for a non-nullable enum field still throws at decode time, as with any required field. Declare the field nullable (TeamSize?) if it may be absent.
accept_field_value #
Set accept_field_value: true on a field that you want to write with a Firestore FieldValue
(serverTimestamp, arrayUnion, arrayRemove, increment, delete). The generator adds:
- a
FieldValue? <field>FieldValueproperty on the model, excluded from JSON, thattoFirestore()writes in place of the regular value when it is not null; - an
UpdatedValue<FieldValue?>? <field>FieldValueparameter on theupdate<Model>method.
// Let the server set created_at: the DateTime passed here is overridden on write.
final team = Team(
name: 'Blue',
userCount: 0,
createdAt: DateTime.now(),
labels: const [],
teamSize: TeamSize.small,
createdAtFieldValue: FieldValue.serverTimestamp(),
);
await queryService.addTeam(team: team);
// Append to the labels array without reading the document.
await queryService.updateTeam(
teamId: teamId,
labelsFieldValue: UpdatedValue(FieldValue.arrayUnion(['vip'])),
);
Full annotated example #
This configuration is adapted from the package's own test configuration
(test/firestore_builder_config.yaml); every snippet of generated code in this README comes
from it.
firestore_builder:
project_name: firestore_builder
output: test/output
use_riverpod: true
collections:
- collection:
collection_name: teams
model_name: team
fields:
- name: String
- description: String?
- user_count: int
- created_at: # DateTime, writable with a FieldValue
type: DateTime
accept_field_value: true
- dates: List<Timestamp?>? # nullable list of nullable timestamps
- bytes: List<List<int>>? # nested lists
- labels:
type: List<String>
accept_field_value: true # arrayUnion / arrayRemove
- presences: Map<String, bool>? # String keys only
- team_ref: DocumentReference?
- team_size: # enum declared in lib/models/enums/team_size.dart
type: TeamSize
path: models/enums/team_size.dart
unknown_enum_value: small
- team_sizes: # list of enums, same fallback
type: List<TeamSize>?
path: models/enums/team_size.dart
unknown_enum_value: small
sub_collections: # teams/{teamId}/users
- collection:
collection_name: users
model_name: user
fields:
- name: String
- current_job: String?
- age: int
sub_collections: # teams/{teamId}/users/{userId}/items
- collection:
collection_name: items
model_name: item
fields:
- name: String
- collection: # teams/{teamId}/messages
collection_name: messages
model_name: message
fields:
- content: String
- date: Timestamp?
- collection:
collection_name: tasks
model_name: task
fields:
- name: String
- description: String?
- done: bool
Generated code walkthrough #
For the configuration above, with output: lib/firestore, the generator writes:
lib/firestore/
├── converters/
│ └── freezed_converters.dart # only if DateTime, Timestamp or DocumentReference is used
├── models/
│ ├── team.dart # Team, TeamId
│ ├── user.dart # User, UserId, UserPath
│ ├── item.dart # Item, ItemId, ItemPath
│ ├── message.dart # Message, MessageId, MessagePath
│ ├── task.dart # Task, TaskId
│ └── updated_value.dart # UpdatedValue<T> and one class per custom type
├── services/
│ ├── firestore_reference_service.dart
│ ├── firestore_query_service.dart
│ └── firestore_stream_service.dart
└── states/ # only with use_riverpod: true
├── team_states.dart
├── user_states.dart
├── item_states.dart
├── message_states.dart
└── task_states.dart
build_runner then adds a .freezed.dart and a .g.dart part next to each model. All files are
formatted with dart_style.
Naming at a glance #
From collection_name: teams and model_name: team:
| Generated | Name |
|---|---|
| Model, id, path classes | Team, TeamId, TeamPath (path only for subcollections) |
| Keys | Team.collectionKey, Team.nameFieldKey, Team.userCountFieldKey, ... |
| Reference service | teamsCollection(), teamReference() |
| Query service | getTeamsCollection(), getTeamsCollectionWhere(), getTeam(), addTeam(), setTeam(), updateTeam(), deleteTeam() |
| Stream service | teamsCollectionStream(), teamsCollectionWhereStream(), teamStream() |
| Riverpod | teamStreamProvider, teamProvider, teamCollectionStreamProvider, teamCollectionProvider |
Models #
models/team.dart (trimmed):
@Freezed(toJson: true, fromJson: true)
abstract class Team with _$Team {
const factory Team({
@JsonKey(name: Team.nameFieldKey) required String name,
@JsonKey(name: Team.userCountFieldKey) required int userCount,
@DateTimeConverter()
@JsonKey(name: Team.createdAtFieldKey)
required DateTime createdAt,
@JsonKey(name: Team.labelsFieldKey) required List<String> labels,
@JsonKey(name: Team.teamSizeFieldKey, unknownEnumValue: TeamSize.small)
required TeamSize teamSize,
@JsonKey(name: Team.descriptionFieldKey) String? description,
@TimestampConverter()
@JsonKey(name: Team.datesFieldKey)
List<Timestamp?>? dates,
@JsonKey(name: Team.presencesFieldKey) Map<String, bool>? presences,
@DocumentReferenceConverter()
@JsonKey(name: Team.teamRefFieldKey)
DocumentReference? teamRef,
@JsonKey(includeFromJson: false, includeToJson: false)
FieldValue? createdAtFieldValue,
@JsonKey(includeFromJson: false, includeToJson: false)
@Default(TeamId(''))
TeamId teamId,
}) = _Team;
factory Team.fromFirestore(DocumentSnapshot<Map<String, Object?>> snapshot) {
final data = snapshot.data();
return Team.fromJson(data!).copyWith(teamId: TeamId(snapshot.id));
}
factory Team.fromJson(Map<String, dynamic> json) => _$TeamFromJson(json);
const Team._();
static const String collectionKey = 'teams';
static const String nameFieldKey = 'name';
static const String userCountFieldKey = 'user_count';
// ... one constant per field
Map<String, Object?> toFirestore() {
final json = toJson();
if (createdAtFieldValue != null) {
json[createdAtFieldKey] = createdAtFieldValue;
}
// ... same for labelsFieldValue
return json;
}
}
@Freezed(toJson: true, fromJson: true)
abstract class TeamId with _$TeamId {
const factory TeamId(String value) = _TeamId;
factory TeamId.fromJson(Map<String, dynamic> json) => _$TeamIdFromJson(json);
}
What to know:
- the document id is not a Firestore field.
teamIdis excluded from JSON and filled byfromFirestorefromsnapshot.id. A model you build yourself hasTeamId('')until it is read back from Firestore; TeamIdis a Freezed value class: two ids with the samevalueare equal, which makes them safe as provider family arguments and map keys;- models of subcollections get an extra
<Model>Pathclass bundling the document id and all parent ids, used as the family argument of the Riverpod providers:
@Freezed(toJson: true, fromJson: true)
abstract class UserPath with _$UserPath {
const factory UserPath({required UserId userId, required TeamId teamId}) = _UserPath;
factory UserPath.fromJson(Map<String, dynamic> json) => _$UserPathFromJson(json);
}
Converters #
converters/freezed_converters.dart is generated only when at least one field uses DateTime,
Timestamp or DocumentReference, and it only contains the converters that are needed. The
DateTime one is the only real conversion:
class DateTimeConverter implements JsonConverter<DateTime, Timestamp> {
const DateTimeConverter();
@override
DateTime fromJson(Timestamp timestamp) {
return timestamp.toDate();
}
@override
Timestamp toJson(DateTime dateTime) {
return Timestamp.fromDate(dateTime);
}
}
TimestampConverter and DocumentReferenceConverter return their input unchanged: they exist
so json_serializable accepts these Firestore types in toJson / fromJson.
UpdatedValue #
models/updated_value.dart holds the wrapper used by every update<Model> method. Wrapping the
value is what lets the method tell "not passed" (null parameter, field untouched) from
"set to null" (UpdatedValue(null), field written as null).
class UpdatedValue<T> {
const UpdatedValue(this.value);
final T value;
}
Custom classes get their own wrapper so the value can be serialized before being sent. One class
is generated per custom type, shared between its nullable and non-nullable uses, and List /
Map of a custom type get a List / Map suffixed variant:
@Freezed(toJson: true, fromJson: true)
abstract class UpdatedValueTeamSize with _$UpdatedValueTeamSize {
const factory UpdatedValueTeamSize(TeamSize teamSize) = _UpdatedValueTeamSize;
// ...
Object? get value => toJson()['teamSize'];
}
@Freezed(toJson: true, fromJson: true)
abstract class UpdatedValueTeamSizeList with _$UpdatedValueTeamSizeList {
const factory UpdatedValueTeamSizeList(List<TeamSize>? teamSizeList) =
_UpdatedValueTeamSizeList;
// ...
Object? get value => toJson()['teamSizeList'];
}
Reference service #
services/firestore_reference_service.dart is the only place that touches FirebaseFirestore.
It exposes a typed CollectionReference and DocumentReference per collection, with the
withConverter wiring done for you. Subcollections require their parent ids:
class FirestoreReferenceService {
const FirestoreReferenceService({required FirebaseFirestore firestore})
: _firestore = firestore;
final FirebaseFirestore _firestore;
CollectionReference<Team> teamsCollection() {
return _firestore
.collection(Team.collectionKey)
.withConverter(
fromFirestore: (snapshot, _) {
return Team.fromFirestore(snapshot);
},
toFirestore: (value, _) {
return value.toFirestore();
},
);
}
DocumentReference<Team> teamReference({required TeamId teamId}) {
return teamsCollection().doc(teamId.value);
}
CollectionReference<User> usersCollection({required TeamId teamId}) {
return teamReference(teamId: teamId)
.collection(User.collectionKey)
.withConverter(/* same pattern */);
}
DocumentReference<User> userReference({
required UserId userId,
required TeamId teamId,
}) {
return usersCollection(teamId: teamId).doc(userId.value);
}
// ... itemsCollection, itemReference, messagesCollection, ...
}
Use it directly whenever you need something the other services do not cover (batches,
transactions, pagination, limit, and so on):
final batch = FirebaseFirestore.instance.batch();
batch.set(referenceService.teamReference(teamId: teamId), team);
await batch.commit();
Query service #
services/firestore_query_service.dart wraps the reference service with one-shot operations.
For every collection:
| Method | Returns | Notes |
|---|---|---|
getTeamsCollection() |
Future<List<Team>> |
Whole collection. |
getTeamsCollectionWhere({required where}) |
Future<List<Team>> |
where receives the typed CollectionReference<Team> and returns a Query<Team>. |
getTeam({required teamId}) |
Future<Team?> |
null if the document does not exist. |
addTeam({required team}) |
Future<String> |
Auto generated id, returned. |
setTeam({required teamId, required team}) |
Future<void> |
Writes the whole document at teamId. |
updateTeam({required teamId, ...}) |
Future<void> |
One optional UpdatedValue parameter per field. |
deleteTeam({required teamId}) |
Future<void> |
Subcollection methods add the parent ids as required named parameters:
getUsersCollection({required teamId}),
getItem({required itemId, required teamId, required userId}), and so on.
// Query with typed keys.
final doneTasks = await queryService.getTasksCollectionWhere(
where: (collection) => collection
.where(Task.doneFieldKey, isEqualTo: true)
.orderBy(Task.nameFieldKey),
);
// Subcollection: the parent id is part of the signature.
final users = await queryService.getUsersCollection(teamId: teamId);
final userId = await queryService.addUser(
teamId: teamId,
user: const User(name: 'Ada', age: 36),
);
// Partial update: only `name`, `description` and `team_size` are written.
await queryService.updateTeam(
teamId: teamId,
name: const UpdatedValue('Blue team'),
description: const UpdatedValue(null), // sets description to null
teamSize: const UpdatedValueTeamSize(TeamSize.large),
);
The generated updateTeam shows how the partial update is built:
Future<void> updateTeam({
required TeamId teamId,
UpdatedValue<String>? name,
UpdatedValue<String?>? description,
UpdatedValue<int>? userCount,
UpdatedValue<DateTime>? createdAt,
// ...
UpdatedValueTeamSize? teamSize,
UpdatedValueTeamSizeList? teamSizes,
UpdatedValue<FieldValue?>? createdAtFieldValue,
UpdatedValue<FieldValue?>? labelsFieldValue,
}) async {
final data = {
if (name != null) Team.nameFieldKey: name.value,
if (description != null) Team.descriptionFieldKey: description.value,
if (teamSize != null) Team.teamSizeFieldKey: teamSize.value,
if (createdAtFieldValue != null)
Team.createdAtFieldKey: createdAtFieldValue.value,
// ...
};
if (data.isEmpty) {
return;
}
await _firestoreReferenceService.teamReference(teamId: teamId).update(data);
}
About set<Model>: setTeam(teamId: id, team: team) writes team at the document id,
creating it or replacing it entirely. The team.teamId carried by the model is ignored (it is
excluded from JSON anyway), so there is nothing to synchronize between the two arguments. For
subcollections the parent ids come from the parameters as well.
Stream service #
services/firestore_stream_service.dart mirrors the read side of the query service with
Streams backed by snapshots():
Stream<List<Team>> teamsCollectionStream();
Stream<List<Team>> teamsCollectionWhereStream({
required Query<Team> Function(CollectionReference<Team>) where,
});
Stream<Team?> teamStream({required TeamId teamId});
Stream<List<User>> usersCollectionStream({required TeamId teamId});
Stream<User?> userStream({required UserId userId, required TeamId teamId});
StreamBuilder<List<Task>>(
stream: streamService.tasksCollectionWhereStream(
where: (collection) => collection.where(Task.doneFieldKey, isEqualTo: false),
),
builder: (context, snapshot) => Text('${snapshot.data?.length ?? 0} tasks left'),
);
Riverpod states #
With use_riverpod: true, every service file also declares its provider, all autoDispose:
final firestoreProvider = Provider.autoDispose<FirebaseFirestore>((ref) {
return FirebaseFirestore.instance;
});
final firestoreReferenceServiceProvider =
Provider.autoDispose<FirestoreReferenceService>((ref) {
return FirestoreReferenceService(firestore: ref.watch(firestoreProvider));
});
final firestoreQueryServiceProvider = Provider.autoDispose<FirestoreQueryService>(...);
final firestoreStreamServiceProvider = Provider.autoDispose<FirestoreStreamService>(...);
To point every generated service at another instance (the emulator, a fake in tests), override
firestoreProvider:
ProviderScope(
overrides: [firestoreProvider.overrideWithValue(fakeFirestore)],
child: const MyApp(),
);
Without Riverpod, pass the instance to FirestoreReferenceService(firestore: ...) yourself.
Each collection gets a states/<model>_states.dart file with four providers. Here is
states/team_states.dart:
final teamStreamProvider = StreamProvider.autoDispose.family<Team?, TeamId>((
ref,
teamId,
) {
final service = ref.watch(firestoreStreamServiceProvider);
return service.teamStream(teamId: teamId);
});
final teamProvider = Provider.autoDispose.family<Team?, TeamId>((ref, teamId) {
final stream = ref.watch(teamStreamProvider(teamId));
return stream.value;
});
final teamCollectionStreamProvider = StreamProvider.autoDispose<List<Team>>((
ref,
) {
final service = ref.watch(firestoreStreamServiceProvider);
return service.teamsCollectionStream();
});
final teamCollectionProvider = Provider.autoDispose<List<Team>?>((ref) {
final stream = ref.watch(teamCollectionStreamProvider);
return stream.value;
});
<model>StreamProviderand<model>CollectionStreamProviderexpose theAsyncValue(loading, error, data);<model>Providerand<model>CollectionProviderreturnAsyncValue.value:nullonly during the first load, the previous value while the stream reloads, and if the stream errors before producing a value the plain provider rethrows. Watch<model>StreamProviderwhen you need to handleAsyncError.
Subcollections are families keyed by the parent: the document providers take the
<Model>Path, the collection providers take the parent's id (TeamId for a direct child) or
the parent's path (UserPath for deeper levels):
// states/user_states.dart
final userProvider = Provider.autoDispose.family<User?, UserPath>(...);
final userCollectionProvider = Provider.autoDispose.family<List<User>?, TeamId>(...);
// states/item_states.dart
final itemProvider = Provider.autoDispose.family<Item?, ItemPath>(...);
final itemCollectionProvider = Provider.autoDispose.family<List<Item>?, UserPath>(...);
class UserDetails extends ConsumerWidget {
const UserDetails({required this.teamId, required this.userId, super.key});
final TeamId teamId;
final UserId userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final path = UserPath(userId: userId, teamId: teamId);
final user = ref.watch(userProvider(path));
final items = ref.watch(itemCollectionProvider(path));
if (user == null) {
return const CircularProgressIndicator();
}
return Text('${user.name} has ${items?.length ?? 0} items');
}
}
Writes go through the query service provider:
await ref.read(firestoreQueryServiceProvider).updateUser(
userId: userId,
teamId: teamId,
age: const UpdatedValue(37),
);
DevTools extension #
The package ships a DevTools extension that builds the configuration file for you. It reads
project_name from your pubspec.yaml, loads an existing firestore_builder_config.yaml if
there is one, and saves the file at the root of your project.
- Launch your app in debug mode
- Open DevTools
- Select the firestore_builder tab and enable the extension (DevTools records the choice in
devtools_options.yamlat the root of your project)
- Build the configuration: collections, subcollections, fields, types,
accept_field_value,pathandunknown_enum_valuefor custom classes
- Save: this writes
firestore_builder_config.yamlat the root of your project. Then run the two generation commands as usual
Workflow and tips #
-
Run both commands after every change to the config:
dart run firestore_builderthendart run build_runner build --delete-conflicting-outputs. The generator only writes the plain.dartsources; Freezed and json_serializable parts come frombuild_runner. -
Keep
outputunderlib/. Generated files import each other throughpackage:<project_name>/<output minus lib/>/..., so an output folder outsidelib/produces imports that do not resolve. -
The output folder belongs to the generator. With the default
clear_output_folder: true, every.dartfile underoutputis deleted before generating, recursively, including files you may have added there. Keep your own code outsideoutput. Setclear_output_folder: falseif you need to keep the folder untouched, knowing that files for removed collections will then linger. -
Commit the generated code. It is plain Dart, reviewable in pull requests, and your app compiles without running the generator. Whether you also commit the
.freezed.dart/.g.dartparts is the same decision as for the rest of your codebase. -
Custom config path.
dart run firestore_builder path/to/config.yaml. The file must end with.yaml. Only the config file path and theoutputfolder are resolved against the directory you run the command from; a fieldpathis never resolved against it, it becomespackage:<project_name>/<path minus lib/>as is. -
Quoting types in YAML.
Map<String, bool>?andList<Timestamp?>?work unquoted in the test configuration. If your YAML tooling complains about<,,or?, quote the type:"Map<String, bool>?". -
Nested custom classes and
explicit_to_json. json_serializable only callstoJson()on nested objects whenexplicit_to_jsonis enabled. If a field holds a custom class (not an enum), add abuild.yamlat your project root:targets: $default: builders: json_serializable: options: explicit_to_json: true
Limitations and FAQ #
Unsupported Firestore types. GeoPoint, Blob, num, dynamic and any other type not in
the supported types table are rejected with Type X is not recognized.
path cannot rescue them: it only imports a file of your own package
(package:<project_name>/...) and no converter is generated for them.
Nested maps. The map value type must not contain a comma, so
Map<String, Map<String, T>> is not parsed; Map<String, List<T>> is fine. Put the inner map
in a custom class with fromJson / toJson instead.
Why does my new model have an empty id? Ids are assigned by Firestore. addTeam returns the
new id as a String; read the document back, or copyWith(teamId: TeamId(id)), to get a model
with its id set.
teamProvider returns null: loading, missing or error? <model>Provider unwraps
AsyncValue.value, so null means "no snapshot yet" or "document does not exist", and a stream
error before the first value is rethrown by the provider. Watch <model>StreamProvider when you
need to tell the cases apart or handle AsyncError.
Does the generator validate Firestore rules or indexes? No. It generates client code only.
Contributing and license #
Issues and pull requests are welcome on
GitHub. The generator is exercised by
test/src/generators_test.dart, which regenerates test/output/ from
test/firestore_builder_config.yaml.
Released under the MIT license.