gql-dart / ferry

Stream-based strongly typed GraphQL client for Dart
https://ferrygraphql.com/
MIT License
599 stars 114 forks source link

Fragment instances create/update #489

Open zombie6888 opened 1 year ago

zombie6888 commented 1 year ago

In my schema i have many nested objects with the same data set. One of the key feature for me is fragment. I use fragment objects for different screens/states. However, sometimes i need to create new instance or update just one or few fields of my state objects. With request data nested classes i can create new instance with desired fields or create copy by using builder and rebuild methods. But it doesn't work for fragment generated classes. Is there a way to achieve same behavior with fragments?

zombie6888 commented 1 year ago

I found workaround:

typedef MyType = GMyTypeFragment;
typedef MyTypeBuilder = dynamic Function(GMyTypeFragmentDataBuilder);

extension MyTypeExtension on GMyTypeFragment {
  static MyType createInstance(MyTypeBuilder updates) =>
      GMyTypeFragmentData(updates);

  GMyTypeFragmentData get data =>
      GMyTypeFragmentData.fromJson(toJson()) ?? GMyTypeFragmentData();

  GMyTypeFragment rebuild(MyTypeBuilder updates) =>
      data.rebuild(updates);
}

Instaed of GMyTypeFragment i use MyType. When i need to create new instance of fragment i call MyTypeExtension.createInstance() and instance.rebuild() when i need to update some fields.

knaeckeKami commented 1 year ago

Hi!

This is not easily possible in the current architecture. Here is an example on why this would cause issues:

Take this query:


fragment heroName on Character {
    name
}

fragment  heroId  on Character {
    id
}

query HeroWith2Fragments($first: Int) {
    hero(episode: JEDI) {
        ...heroName
        ...heroId
    }
}

It will generate two fragment classes

abstract class GheroName {
  String get G__typename;
  String get name;
  Map<String, dynamic> toJson();
}
abstract class GheroId {
  String get G__typename;
  String get id;
  Map<String, dynamic> toJson();
}

And the actual object used by the query looks like this:

abstract class GHeroWith2FragmentsData_hero
    implements
        Built<GHeroWith2FragmentsData_hero,
            GHeroWith2FragmentsData_heroBuilder>,
        GheroName,
        GheroId {
  GHeroWith2FragmentsData_hero._();

  factory GHeroWith2FragmentsData_hero(
          [Function(GHeroWith2FragmentsData_heroBuilder b) updates]) =
      _$GHeroWith2FragmentsData_hero;

  static void _initializeBuilder(GHeroWith2FragmentsData_heroBuilder b) =>
      b..G__typename = 'Character';
  @override
  @BuiltValueField(wireName: '__typename')
  String get G__typename;
  @override
  String get name;
  @override
  String get id;
  static Serializer<GHeroWith2FragmentsData_hero> get serializer =>
      _$gHeroWith2FragmentsDataHeroSerializer;
  @override
  Map<String, dynamic> toJson() => (_i1.serializers.serializeWith(
        GHeroWith2FragmentsData_hero.serializer,
        this,
      ) as Map<String, dynamic>);
  static GHeroWith2FragmentsData_hero? fromJson(Map<String, dynamic> json) =>
      _i1.serializers.deserializeWith(
        GHeroWith2FragmentsData_hero.serializer,
        json,
      );
}

It will implement both fragment classes.

Now if there was an rebuild() or copyWith() method defined in that fragment, the data type would be be able to implement both fragments as there is a conflict in the methods names with with different signatures, similar to this sample code:

abstract class Car {

  void drive(Driver driver);

}

abstract class Driver {

  void drive(Car car);

}

abstract class SelfDrivingCar implements Car, Driver {

  // cannot define both drive() methods, no overloading in dart
  void drive(Car car) {

  }

  void drive(Driver driver){

  }

}
knaeckeKami commented 1 year ago

On a second thought, this could be done via extension methods on the fragment classes, similar to the when extensions on inline fragments with type condition

bawahakim commented 6 months ago

@knaeckeKami Would also love this feature!

typedef MyType = GMyTypeFragment; typedef MyTypeBuilder = dynamic Function(GMyTypeFragmentDataBuilder);

extension MyTypeExtension on GMyTypeFragment { static MyType createInstance(MyTypeBuilder updates) => GMyTypeFragmentData(updates);

GMyTypeFragmentData get data => GMyTypeFragmentData.fromJson(toJson()) ?? GMyTypeFragmentData();

GMyTypeFragment rebuild(MyTypeBuilder updates) => data.rebuild(updates); }

Thanks @zombie6888 , however I think this doesn't work for nested types? If I have GMyTypeAFragment which has a nested GMyTypeBFragment, and I want to add/replace data for TypeB, the type for GMyTypeBFragment becomes something like GMyTypeA_TypeBBuilder, which tightly couples TypeB to TypeA, so I can't exactly use an extension method.

E.g. with above code (also applies without extension)

// Generated
class GMyTypeAFragment {
  int someField;
  GMytypeAFragment_typeBFragment typeBFragment
}

// Usage
final myTypeA = MyTypeAExtension.createInstance(
  (b) => b..typeBFragment = <-- Here it expects` GMytypeAFragment_typeBFragmentBuilder` which is specific only to the TypeB within TypeA
);

Would appreciate if anyone could provide any help on this, because it makes it a nightmare to modify local/mock data :D

bawahakim commented 6 months ago

I kinda figured out a workaround through extensions as well, although it adds boilterplate because we have to define each nested resource. They key is to separate the fragments into root and "complete" (or nested) fragments, so we can build on top of the other.

@knaeckeKami I believe something like this could possible be integrated in the ferry generator?


typedef TypeABuilder = dynamic Function(GTypeARootDataBuilder);

extension TypeARootBuilderExtension on GTypeARoot {
  static GTypeARootData createInstance(
    TypeABuilder updates,
  ) =>
      GTypeARootData(updates);

  GTypeARootData get rootData =>
      GTypeARootData.fromJson(toJson()) ?? GTypeARootData();

  GTypeARoot rebuild(TypeABuilder updates) =>
      rootData.rebuild(updates);
}

extension TypeABuilderExtension on GTypeAComplete {
  static GTypeACompleteData createInstance({
    required GTypeARoot root,
    required GTypeB typeB,
    required List<GTypeC> typeCList,
  }) {
    final merged = {
      ...root.toJson(),
      'TypeB': typeB.toJson(),
      'TypeC':
          typeCList.map((e) => e.toJson()).toList(),
    };

    return GTypeACompleteData.fromJson(merged)!;
  }

  GTypeACompleteData get data =>
      GTypeACompleteData.fromJson(toJson()) ??
      GTypeACompleteData();

  GTypeAComplete rebuild({
    TypeABuilder? rootUpdates,
    GTypeB? updatedTypeB,
    List<GTypeC>? typeCList,
  }) {
    final updatedRoot =
        rootUpdates != null ? rootData.rebuild(rootUpdates) : rootData;

    return createInstance(
      root: updatedRoot,
      typeB: updatedTypeB ?? data.TypeB,
      typeCList: typeCList ??
          data.TypeC?.toList() ??
          <GTypeC>[],
    );
  }
}