schultek / dart_mappable

Improved json serialization and data classes with full support for generics, inheritance, customization and more.
https://pub.dev/packages/dart_mappable
MIT License
135 stars 20 forks source link

How to Keep Generics While Serializing #179

Closed gabrielmcreynolds closed 3 months ago

gabrielmcreynolds commented 3 months ago

I cannot figure out how to get the correct type of a class when it is deserialized from JSON.

I have a class Location that has two subclasses as seen below:

// location.dart
@MappableClass(discriminatorKey: 'latitude')
abstract class Location with LocationMappable {
  const Location();

  factory Location.fromJson(Map<String, dynamic> json) =>
      LocationMapper.fromJson(json);
}

@MappableClass(discriminatorValue: MappableClass.useAsDefault)
class PointLocation extends Location with PointLocationMappable {
  final double latitude;
  final double longitude;

  const PointLocation({required this.latitude, required this.longitude});

  factory PointLocation.fromJson(Map<String, dynamic> json) =>
      PointLocationMapper.fromJson(json);

}

@MappableClass(discriminatorValue: null)
class PolygonLocation extends Location with PolygonLocationMappable {
  // a custom mapper exists for LatLng
  final IList<LatLng> points;

  const PolygonLocation({required this.points});

  factory PolygonLocation.fromJson(Map<String, dynamic> json) =>
      PolygonLocationMapper.fromJson(json);
}

Now I have another class that utilizes Location as a generic parameter as seen below:

// artifact.dart
@MappableClass(discriminatorKey: 'id')
class Artifact<T extends Location> with ArtifactMappable<T> {
  final T location;

  const Artifact({
    required this.title,
  });

  factory Artifact.fromJson(Map<String, dynamic> json) =>
      ArtifactMapper.fromJson(json);
}

@MappableClass(discriminatorValue: MappableClass.useAsDefault)
class ServerArtifact<T extends Location> extends Artifact<T>
    with ServerArtifactMappable<T> {
  final String id;

  const ServerArtifact({
    required this.id,
    required super.location
  });

  @override
  factory ServerArtifact.fromJson(Map<String, dynamic> json) =>
      ServerArtifactMapper.fromJson(json);

  @override
  String toString() {
    return 'Type: $T.';
  }
}

@MappableClass(discriminatorValue: null)
class NewArtifact<T extends Location> extends Artifact<T>
    with NewArtifactMappable<T> {
  const NewArtifact({
    required super.location,
  });

  @override
  factory NewArtifact.fromJson(Map<String, dynamic> json) =>
      NewArtifact.fromJson(json);
}

Note: I am using fromJson instead of fromMap as seen in the docs.

Now, whenever I am using serializing an Artifact from JSON it correctly sets the location property to be the correct location (i.e. PointLocation or PolygonLocation) however the generic type of Artifact is always Location not PointLocation even if location field is a PointLocation class as shown in the toString of ServerArtifact the type of T is Location.

I can't just do MapperContainer.globals.fromJson<Artifact<PointLocation>>(...); because typically Artifact is being deserialized from a list where each Artifact could have a PointLocation or a PolygonLocation type parameter.

I see in the docs that it is recommended to have a type field in the JSON, however I cannot control the JSON response from the server so cannot add that type field, and I thought that by adding a discriminator the generator would be able to figure it out.

Not sure if this is a bug or (more likely :) I'm doing something wrong so any help is appreciated!

schultek commented 3 months ago

I have for sure never seen discriminatorKey being used like this, but at the same time I don't see anything wrong with it from just now.

Can you add a snippet on how you call the fromJson method and what you expect + actually get as a result.

gabrielmcreynolds commented 3 months ago

Thanks for responding :) I'm calling the fromJson from another class. Here is a simplified version of that class:

@MappableClass(discriminatorValue: ServerTour.checkType)
class ServerTour extends Tour with ServerTourMappable {
  final IList<ServerArtifact> artifacts;

  const ServerTour({
    required super.id,
    required this.artifacts,
  });

  static bool checkType(value) {
    return value is Map && value['businessId'] != null;
  }

  factory ServerTour.fromJson(Map<String, dynamic> json) =>
      ServerTourMapper.fromJson(json);
}

Then I call ServerTour.fromJson(...json).

What is printed is:

ServerTour(id: 6f263192-ac3c-4da1-a88e-1a3eec232d4e,  artifacts: [
 Type: Location. ServerArtifact(location: PointLocation(.... other data)),
 Type: Location. ServerArtifact(location: PointLocation(.... other data)),
)
gabrielmcreynolds commented 3 months ago

Currently my workaround I've found is to create a changeType method in Artifact where I manually reconstruct the Artifact using a MappingHook in my Tour class like the following:

// in my artifact.dart class
  @mustBeOverridden
  Artifact<G> changeType<G extends Location>() {
    return Artifact(
      title: title,
      description: description,
      location: location as G,
    );
  }

Then I add the following MappingHook to my IList property in Tour where the JSON serialization occurs:

class ArtifactsHook extends MappingHook {
  const ArtifactsHook();

  @override
  Object? afterDecode(Object? value) {
    if (value is IList<ServerArtifact>) {
      return value.map((artifact) {
        if (artifact.location is PointLocation) {
          return artifact.changeType<PointLocation>();
        } else {
          return artifact.changeType<PolygonLocation>();
        }
      }).toIList();
    }
    return null;
  }
}

While this works for now, I hope this is not the long-term solution as everytime I change a property in Artifact I would now have to change each changeType property as well.

schultek commented 3 months ago

Ok I got your problem now. However this issue is not really a bug and works as supposed to. The reason is because deserialization works in a top-down manner for choosing the type in nested class structures. So the generic type for Artifact is chosen before Location is decoded, therefore it does not know the exact type of Location yet. I generally think this behavior is correct as the top class may also want to have other values for the generic type.

So for your case I see two solutions. Either use a hook like you did to modify the class after decoding, or reverse the decoding order for location to force a correct decoding of the generic type.

schultek commented 3 months ago

Here is a hook for the 2nd option to apply to Artifact:

class ArtifactHook extends MappingHook {
  const ArtifactHook();
  @override
  Object? beforeDecode(Object? value) {
    if (value is Map<String, dynamic>) {
      if (value['location'] case Map<String, dynamic> l) {
        var location = LocationMapper.fromMap(l);
        return ArtifactMapper.ensureInitialized().decoder(
          {...value, 'location': location},
          DecodingContext(args: () => [location.runtimeType]),
        );
      }
    }
    return value;
  }
}

This swaps the normal order around by first decoding the Location, and then using its type as generic type argument for decoding the Artifact. Also here I pass an updated map to ArtifactMapper to skip decoding the Location twice.

gabrielmcreynolds commented 3 months ago

Thanks for the second hook. That is a much better option. Had to change it a little bit because it was a list but overall pleasantly surprised by the flexibility that dart_mappable provides compared with alternatives. Thanks for all your work on this package!