Firebase Remote Adapter

📖 6 min read

This guide shows how to implement a complete Firebase remote adapter for Datum.

Overview#

Firebase provides real-time database capabilities and works well with Datum's synchronization features. This adapter uses Firestore as the backend.

Setup#

Add Firebase dependencies to your pubspec.yaml:

dependencies:
  cloud_firestore: ^4.0.0
  firebase_core: ^2.0.0

Implementation#

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:datum/datum.dart';

class FirebaseRemoteAdapter<T extends DatumEntityInterface> extends RemoteAdapter<T> {
  final String collectionName;
  final T Function(Map<String, dynamic>) fromMap;
  final FirebaseFirestore? firestore;

  FirebaseRemoteAdapter({
    required this.collectionName,
    required this.fromMap,
    FirebaseFirestore? firestore,
  }) : firestore = firestore ?? FirebaseFirestore.instance;

  CollectionReference<Map<String, dynamic>> get _collection =>
      firestore.collection(collectionName);

  @override
  Future<void> initialize() async {
    // Firebase is initialized globally
  }

  @override
  Future<void> dispose() async {
    // Firebase is disposed globally
  }

  @override
  Future<AdapterHealthStatus> checkHealth() async {
    try {
      // Try to get a document to check connectivity
      await _collection.limit(1).get();
      return AdapterHealthStatus.healthy;
    } catch (e) {
      return AdapterHealthStatus.unhealthy;
    }
  }

  @override
  Future<bool> isConnected() async {
    // Firebase handles connectivity internally
    return true;
  }

  @override
  Future<T?> read(String id, {String? userId}) async {
    try {
      final doc = await _collection.doc(id).get();
      if (!doc.exists) return null;

      final data = doc.data();
      if (data == null) return null;

      // Check user access if userId is provided
      if (userId != null && data['userId'] != userId) {
        return null;
      }

      return fromMap(data);
    } catch (e) {
      throw Exception('Failed to read from Firestore: $e');
    }
  }

  @override
  Future<List<T>> readAll({String? userId, DatumSyncScope? scope}) async {
    try {
      Query<Map<String, dynamic>> query = _collection;

      // Add user filter if provided
      if (userId != null) {
        query = query.where('userId', isEqualTo: userId);
      }

      // Add scope filters if provided
      if (scope != null) {
        for (final filter in scope.query.filters) {
          if (filter is Filter) {
            query = _applyFilter(query, filter);
          }
        }
      }

      // Filter out soft-deleted documents
      query = query.where('isDeleted', isEqualTo: false);

      final snapshot = await query.get();
      return snapshot.docs.map((doc) => fromMap(doc.data())).toList();
    } catch (e) {
      throw Exception('Failed to read from Firestore: $e');
    }
  }

  Query<Map<String, dynamic>> _applyFilter(
    Query<Map<String, dynamic>> query,
    Filter filter,
  ) {
    switch (filter.operator) {
      case FilterOperator.equals:
        return query.where(filter.field, isEqualTo: filter.value);
      case FilterOperator.greaterThan:
        return query.where(filter.field, isGreaterThan: filter.value);
      case FilterOperator.lessThan:
        return query.where(filter.field, isLessThan: filter.value);
      case FilterOperator.greaterThanOrEqual:
        return query.where(filter.field, isGreaterThanOrEqual: filter.value);
      case FilterOperator.lessThanOrEqual:
        return query.where(filter.field, isLessThanOrEqual: filter.value);
      case FilterOperator.isIn:
        return query.where(filter.field, whereIn: filter.value as List);
      case FilterOperator.isNotIn:
        return query.where(filter.field, whereNotIn: filter.value as List);
      case FilterOperator.arrayContains:
        return query.where(filter.field, arrayContains: filter.value);
      default:
        // Firestore doesn't support all operators natively
        return query;
    }
  }

  @override
  Future<void> create(T entity) async {
    try {
      await _collection.doc(entity.id).set(entity.toDatumMap());
    } catch (e) {
      throw Exception('Failed to create in Firestore: $e');
    }
  }

  @override
  Future<void> update(T entity) async {
    try {
      await _collection.doc(entity.id).update(entity.toDatumMap());
    } catch (e) {
      throw Exception('Failed to update in Firestore: $e');
    }
  }

  @override
  Future<void> delete(String id, {String? userId}) async {
    try {
      // Soft delete by updating the document
      await _collection.doc(id).update({'isDeleted': true});
    } catch (e) {
      throw Exception('Failed to delete in Firestore: $e');
    }
  }

  @override
  Future<T> patch({
    required String id,
    required Map<String, dynamic> delta,
    String? userId,
  }) async {
    try {
      await _collection.doc(id).update(delta);

      // Return the updated document
      final updated = await read(id, userId: userId);
      if (updated == null) {
        throw Exception('Document not found after patch');
      }
      return updated;
    } catch (e) {
      throw Exception('Failed to patch in Firestore: $e');
    }
  }

  @override
  Future<List<T>> query(DatumQuery query, {String? userId}) async {
    try {
      Query<Map<String, dynamic>> firestoreQuery = _collection;

      // Add user filter
      if (userId != null) {
        firestoreQuery = firestoreQuery.where('userId', isEqualTo: userId);
      }

      // Filter out soft-deleted documents
      firestoreQuery = firestoreQuery.where('isDeleted', isEqualTo: false);

      // Apply filters
      for (final filter in query.filters) {
        if (filter is Filter) {
          firestoreQuery = _applyFilter(firestoreQuery, filter);
        }
      }

      // Apply sorting
      for (final sort in query.sorting) {
        firestoreQuery = firestoreQuery.orderBy(
          sort.field,
          descending: sort.direction == SortDirection.descending,
        );
      }

      // Apply pagination
      if (query.offset > 0) {
        // Firestore doesn't support offset directly with where clauses
        // This is a simplified implementation
        firestoreQuery = firestoreQuery.limit(query.offset + (query.limit ?? 100));
      }
      if (query.limit != null) {
        firestoreQuery = firestoreQuery.limit(query.limit!);
      }

      final snapshot = await firestoreQuery.get();
      return snapshot.docs.map((doc) => fromMap(doc.data())).toList();
    } catch (e) {
      throw Exception('Failed to query Firestore: $e');
    }
  }

  @override
  Future<DatumSyncMetadata?> getSyncMetadata(String userId) async {
    try {
      final doc = await firestore.collection('sync_metadata').doc(userId).get();
      if (!doc.exists) return null;

      final data = doc.data();
      if (data == null) return null;

      return DatumSyncMetadata.fromMap(data);
    } catch (e) {
      throw Exception('Failed to get sync metadata from Firestore: $e');
    }
  }

  @override
  Future<void> updateSyncMetadata(DatumSyncMetadata metadata, String userId) async {
    try {
      await firestore.collection('sync_metadata').doc(userId).set(metadata.toMap());
    } catch (e) {
      throw Exception('Failed to update sync metadata in Firestore: $e');
    }
  }

  @override
  Stream<DatumChangeDetail<T>>? get changeStream {
    return _collection.snapshots().map((snapshot) {
      // This is a simplified implementation
      // In a real app, you'd need to analyze the changes
      for (final change in snapshot.docChanges) {
        final type = switch (change.type) {
          DocumentChangeType.added => DatumOperationType.create,
          DocumentChangeType.modified => DatumOperationType.update,
          DocumentChangeType.removed => DatumOperationType.delete,
        };

        final data = change.doc.data();
        if (data != null) {
          final entity = fromMap(data);
          return DatumChangeDetail<T>(
            type: type,
            entityId: entity.id,
            userId: entity.userId,
            timestamp: entity.modifiedAt,
            data: entity,
          );
        }
      }
      return null;
    }).where((detail) => detail != null).cast<DatumChangeDetail<T>>();
  }
}

Usage Example#

// Create the adapter
final taskAdapter = FirebaseRemoteAdapter<Task>(
  collectionName: 'tasks',
  fromMap: (map) => Task.fromMap(map),
);

// Register with Datum
final registrations = [
  DatumRegistration<Task>(
    localAdapter: HiveLocalAdapter<Task>(
      boxName: 'tasks',
      fromMap: (map) => Task.fromMap(map),
    ),
    remoteAdapter: taskAdapter,
  ),
];

Firestore Security Rules#

Set up proper security rules for your Firestore database:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Tasks collection - users can only access their own data
    match /tasks/{taskId} {
      allow read, write: if request.auth != null &&
        resource.data.userId == request.auth.uid;
      allow create: if request.auth != null &&
        request.auth.uid == request.resource.data.userId;
    }

    // Sync metadata - users can only access their own metadata
    match /sync_metadata/{userId} {
      allow read, write: if request.auth != null &&
        request.auth.uid == userId;
    }
  }
}

Features#

  • Real-time Synchronization: Firestore's real-time capabilities enable live data sync
  • Offline Support: Built-in offline persistence with automatic sync
  • Security Rules: Granular access control with Firestore security rules
  • Query Support: Rich querying with Firestore's query capabilities
  • Change Streams: Real-time change notifications
  • Scalability: Automatic scaling with Firebase infrastructure

Performance Considerations#

  • Query Limitations: Firestore has specific query limitations (no OR queries, inequality limitations)
  • Indexing: Automatic indexing but can be expensive for complex queries
  • Real-time Updates: Change streams can consume battery and data
  • Batch Operations: Consider batch writes for multiple operations
  • Pagination: Implement cursor-based pagination for large datasets