schultek/dart_mappable

How exactly does serialization (toJson & toMap) work?

Opened this issue · 6 comments

I understand that for deserialization, dart_mappable looks at the first constructor. But how exactly does serialization work? Could you add some more documentation?

  • What is the best way to skip a field during serialization?
  • What do I need keep in mind to to ensure that myClass == MyClassMapper.fromJson(myClass.toJson()) is true?

Side Note Regarding Deserialization:

I was a bit confused by Utilizing Constructors:

dart_mappable never looks at the fields of your model, but rather only at the constructor arguments

It implies that only the constructor is relevant for dart_mappable, but annotations such as MappableField are important as well. And I am not sure about serialization.

What do I need keep in mind to to ensure that myClass == MyClassMapper.fromJson(myClass.toJson()) is true?

The package does its best to ensure that very condition. So usually you don't have to worry about this as it is respected by default. There are however ways to break that when you deliberately want to. From the docs:

(*) Regarding the matching getters: Not-having them won't break your code. However this will lead to desynched serialization (keys missing in your json) and eventually to errors when trying to deserialize back. You will also get a warning in the builder output to know when this happens.

So as long as you don't get that warning while running build_runner (and as long as you don't do any weird stuff with custom hooks) you are safe.

What is the best way to skip a field during serialization?

I unserstand it as you want to skip a field only during seralization, but respect it during deserialization? In that case you want to break 1.? Or do you want to have a field on the class that is ignored both for serialization and deserialization? In the second case just leave it out of the constructor. In the first case I would use a custom MappingHook and delete that field from the encoded map by overriding the afterEncode method.

I'd like to drop in on this. My case also required to skip a field. All my models extend a BaseModel which defines an id and reference field (Firestore doc). These properties are not part of the json but part of the document itself, so I add those fields after serialization. When I leave the id + reference field out of the constructor, this also works BUT i lose the copyWith and other generated methods.

My base class:

@MappableClass()
abstract class BaseModel with BaseModelMappable {
  String id;
  DocumentReference? reference;
  DateTime? docCreatedAt;
  DateTime? docUpdatedAt;
  DocState? docState;

  BaseModel({
    this.id = '',
    this.reference,
    this.docCreatedAt,
    this.docUpdatedAt,
    this.docState = DocState.unknown,
  });

  void meta(DocumentSnapshot<Map<String, dynamic>> doc) {
    id = doc.id;
    reference = doc.reference;
  }

  Future<void> update();

  Future<void> delete();
}

And my data class:

@MappableClass()
class Activity extends BaseModel with ActivityMappable {
  String createdBy;
  Map<ActivityType, String> type;
  ActivityMeasurementUnit measurementUnit;
  Map<ActivityResponse, String> response;
  double value;

  Activity({
    super.id,
    super.reference,
    super.docCreatedAt,
    super.docUpdatedAt,
    super.docState,
    required this.createdBy,
    this.type = const <ActivityType, String>{},
    this.measurementUnit = ActivityMeasurementUnit.unknown,
    this.response = const <ActivityResponse, String>{},
    this.value = 0.0,
  });

  static const collectionPath = FirestorePaths.pathActivities;

  factory Activity.empty() => Activity(createdBy: '');

  static DatabaseService<Activity> get databaseService => DatabaseService(
        fromFirestore: (doc) => ActivityMapper.fromMap(doc.data()!),
      );

  static CollectionReference<Activity> collRef() => databaseService.collRef(Activity.collectionPath);

  static Future<Activity> futureFromId(String id) async => databaseService.futureFromId(
        collRef(),
        id,
      );

  Future<Activity> add() async => databaseService.add(
        collRef(),
        this,
      );

  @override
  Future<void> update() async {
    return;
  }

  @override
  Future<void> delete() async {
    return;
  }
}

Problem with this setup is that the id + reference field will be serialized as part of the class, but they aren't in the actual database.

How would you approach this?

The easiest way right now is to use a hook that just removes these fields from the resulting map:

class RemoveFieldsHook extends MappingHook {
  const RemoveFieldsHook(this.fields);
  final List<String> fields;
  
  @override
  Object? afterEncode(Object? value) {
    if (value is Map) {
      for (var f in fields) {
        value.remove(f);
      }
   }
   return value;
  }
}

@MappableClass(hook: RemoveFieldsHook(['id', 'reference']))
abstract class BaseModel with BaseModelMappable {
  ...
}

Note that since hooks are also inherited and applied to subclasses, this should work for all subclasses also.

Thanks! Was already going in that direction:

class BaseModelHook extends MappingHook {
  final List<String> fields;

  /// Remove fields from the map before encoding, these fields are not part of the model
  const BaseModelHook({this.fields = const ['id', 'path']});

  @override
  Object? afterEncode(Object? value) {
    if (value is Map<String, dynamic>) {
      return {...value}..removeWhere((field, _) => fields.contains(field));
    }

    return value;
  }
}

Note that it is on my radar to add more field specific behaviours but I don't have a lot of time right now to work on my packages so it might take a while for this to land. Therefore I would go with the hook for now.

If you find time to look into that, my suggestion would be to add a special type or value that can be used in a field's hook that tells the package to ignore the field in json.
That way you can dynamically decide to include a field or not.
In my use case, a certain server version expects a field, while other versions just fail.

e.g.

  @override
  Object? beforeEncode(Object? value) {
    if (whatever == true) {
       return Absent(); // exclude this field from json
    }
    return value;
  }