hukusuke1007 / flamingo

[Flutter Library] Flamingo is a firebase firestore model framework library. 🐤
https://pub.dev/packages/flamingo
MIT License
118 stars 20 forks source link

Immutable data types #23

Closed stargazing-dino closed 3 years ago

stargazing-dino commented 3 years ago

Hi ! I've been struggling to find a good approach to modeling firebase data. I started with functional_data and then looked at this library but was discouraged by mutability and then I settled on freezed. However with the new null safety, I've been trying to solve one problem. Having everything be non-null. This includes the references, ids and so on of a document. One thing I've come to realize is that this would need two separate classes. Take for example a user. One class is the base implementation. Nothing to do with firebase. The second (generated) class would be the one after we've associated it with a firebase document. Its reference would never null.

I came up with a close approximation of what it'd look like here:

/// @Flamingo(options) will set the collection ref
/// $User provides immutable `copyWith` implementation or lenses.
@Flamingo(path: 'path/to/users', collectionRef: myCollectionRef)
class User extends $User {
  const User({
    required this.name,
    required this.email,
  });

  final String name;

  final String email;

  UserDoc toDocument(DocumentSnapshot snapshot) => _$toDocument(snapshot);
}

/// This class will be auto generated and be kept inside the part file.
/// $UserDoc will also create `copyWith` and/or lenses.
class UserDoc extends $UserDoc with Document<UserDoc>, Snapshot {
  UserDoc({
    required this.name,
    required this.email,
    required this.reference,
    required this.metadata,
    required this.id,
    required this.exists,
  });

  final String name;

  final String email;

  final DocumentReference reference;

  final SnapshotMetadata metadata;

  final String id;

  final bool exists;

  factory UserDoc.fromSnapshot(DocumentSnapshot snapshot) =>
      _$fromSnapshot(snapshot);

  @override
  Map<String, dynamic> toData() => _$toData(this);
}

// In a separate file
abstract class Document<T> {
  // Sort of the implementation as previously
}

// In a separate file
abstract class Snapshot {
  DocumentReference get reference;

  SnapshotMetadata get metadata;

  String get id;

  bool get exists;
}

One thing I really like about this is that nothing is nullable. If something is we'd have a @nullable annotation or something otherwise we'll throw a parse error.

To update a value for example we could:

// You could either create a user and then attach it to a do
// Or create a UserDoc from non empty a snapshot
final user = User(name: 'kilo', email: 'kilo@gmail.com');
final userDoc = User.toDocument(CollectionRef.doc());

await documentAccessor.save(userDoc.copyWith(name: 'Kilo'));

If this is something you'd be interested in, I could try for a PR or be glad to keep discussing

stargazing-dino commented 3 years ago

So I tried a lot of ways but didn't make much progress.

Here were the things I really wanted:

My proof of concept interestingly works but has some problems I think.

Given a file user.dart

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:fireproof_annotation/fireproof_annotation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.fire.dart';
part 'user.freezed.dart';
part 'user.g.dart';

// fireproof is the lib name in place of flamingo for personal testing
@Fireproof()
abstract class BaseUser {
  const BaseUser({
    required this.name,
    required this.email,
  });

  final String name;

  final String email;
}

It'll first generate a file called user.fire.dart :

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'user.dart';

// **************************************************************************
// FireproofGenerator
// **************************************************************************

@freezed
class User with _$User {
  factory User({
    required String name,
    required String email,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

@freezed
class UserDoc with _$UserDoc {
  factory UserDoc({
    required String name,
    required String email,
    required DocumentReference reference,
  }) = _UserDoc;

  factory UserDoc.fromSnapshot(DocumentSnapshot snapshot) {
    final data = snapshot.data();
    final dataUser = _$UserFromJson(data!);

    return UserDoc(
      name: dataUser.name,
      email: dataUser.email,
      reference: snapshot.reference,
    );
  }
}

Because this runner is set to run before json_serializable and freezed, those builders will consider this part file in the rest of the original file and build their own user.freezed.dart and user.g.dart.

This solution solved 2 out of 3 of my personal requirements but I couldn't think of way you could add easily customize the generated classes for example to add custom getters on UserDoc.

I'd love to know what you think.

@mono0926 I know you are doing similar work with flutter_firestore_ref and I'm wondering if you have any thoughts about this?

stargazing-dino commented 3 years ago

Sorry to spam you! I just don't know where to write what i found as I haven't seen anyone else asking for this. I'm also grateful that you had this awesome framework :)

I finally found a method for me that covered my use cases (although it's not as pretty I'd like). As it turns out, what I really wanted was to be able to fully type wrap the entire firebase database at compile time (that is, considering it a graph, have all the nodes mapped and typed out beforehand by the user) and still have everything also be non-nullable by default.

Honestly, this problem would've been very easy if dart had inherited factories or static methods !!! Also, meta programming would've been very helpful.

So what I ended up doing instead is making a way to create fully type safe wrappers around most firebase types. For example, consider I have a user in a user.dart file:

@freezed
class User with _$User {
  const factory User({
    required String name,
    required String email,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

I decided to make a code generator that will work on top of a user defined mixin _$UserDoc. Why not a class? Well, because it'd force them to write out all the fields and factory as well.

So, in the same user.dart file, if they define this:

@Fireproof()
mixin _$UserDoc on FireDocumentReference<User, UserDoc> {
  @override
  FireCollectionReference get parent => super.parent;

  @Collection(name: 'badges')
  late final FireCollectionReference<BadgeDoc> badgesCollection;
}

After running build runner I'll generate a class called UserDoc inside user.fire.dart. It mirrors DocumentReference from firestore except for that all of its methods are type safe.

class UserDoc with _$UserDoc implements FireDocumentReference<User, UserDoc> {
  final User data;

  final DocumentReference reference;

  final String id;

  final SnapshotMetadata metadata;

  // TODO: How do I get the parent?
  FireCollectionReference get parent => reference.parent;

  String get path => reference.path;

  FirebaseFirestore get firestore => reference.firestore;

  UserDoc({
    required this.data,
    required this.reference,
    required this.id,
    required this.metadata,
  });

  factory UserDoc.fromSnapshot(DocumentSnapshot snapshot) {
    final data = snapshot.data();

    if (!snapshot.exists) {
      throw StateError('Snapshot $snapshot does not exist');
    }

    if (data == null) {
      throw StateError('No data for snapshot $snapshot');
    }

    return UserDoc(
      data: User.fromJson(data),
      id: snapshot.id,
      metadata: snapshot.metadata,
      reference: snapshot.reference,
    );
  }

  @override
  Future<void> delete() async {
    await reference.delete();
  }

  @override
  Future<UserDoc> get([GetOptions? options]) async {
    final documentSnapshot = await reference.get();

    return UserDoc.fromSnapshot(documentSnapshot);
  }

  @override
  Future<void> set(User data, [SetOptions? options]) async {
    await reference.set(data.toJson(), options);
  }

  @override
  Stream<UserDoc> snapshots({bool includeMetadataChanges = false}) async* {
    yield* reference
        .snapshots(includeMetadataChanges: includeMetadataChanges)
        .map(
      (documentSnapshot) {
        return UserDoc.fromSnapshot(documentSnapshot);
      },
    );
  }

  @override
  Future<void> update(User data) async {
    await reference.update(data.toJson());
  }
}

Why not a generic class that has a type T for User? Well that's because I do a lot of toJson and fromJson and abstract classes cannot define static methods or factories so there'd be no way to define that that's even possible on a sub type.... sigh i know dumb.

I'm probably going to publish this as a package once I figure out the mapping the entire database but I wanted to share this in case it helps you in any way. I'll close this as I've mostly been rambling.

Feel free to reopen though

hukusuke1007 commented 3 years ago

@Nolence Thank you for your suggestion. I think that the flamingo is a problem because it can be use as mutable. I'm thinking of updating it so that it can be used immutable in the future, but I haven't had time to do it yet.

I will consider immutable design in the near future, so I will think about it at that time.

Thank you

stargazing-dino commented 3 years ago

No rush :) I also really enjoyed using your package and just wanted to help out in some way !

Feel free to mention me if you'd like me to help out with any issues. I'd be happy to help if i can