schultek/dart_mappable

mapper instance methods

Closed this issue · 5 comments

Having a separate mapper class is really powerful because it allows you to have instance methods that can be consumed in a generic sense.

@MappableClass()
class PbDrill with PbDrillMappable {
  final String id;
  final String title;

  PbDrill(
    this.id,
    this.title,
  );
}

However, dart_mappable doesn't seem to have these instance methods. Here's what they might look like

// dart_mappable serialization instance methods that don't exist today
PbDrillMapper().toJson();
PbDrillMapper().fromJson();

This is important because you can then create generic tooling that takes in any serializable class instance and access serialization for it.

// Some library code, consumed by any app
class MyStorageBox<T extends Mappable> {

  MyStorageBox(this.key, this.mapper);

  final String key;

  final ClassMapperBase<T> mapper;

  Future<void> save(T value)async{
    final string = mapper.toJson(value);
    await saveToDatabase(key, string);
  }

  Future<void> load() async {
    final string = getFromDatabase(key);
    return mapper.fromJson(string);
  }
}

And it could then be consumed as such

// App code, storing any mappable class
Future<void> main() async {
  final storageBox = MyStorageBox('my-drill', PbDrillMapper());
  await storageBox.save(PbDrill("drill-1", "40-yard dash"));
  final data = await storageBox.load();
}

Common use cases:

  • stream & save mapper for firestore collections
  • local storage mapper
  • database mapper
  • extensions on PbDrillMapper for fromJsonList() / toJsonList()
  • extensions on PbDrillMapper for fromPocketbaseModel / fromFirestoreSnapshot
  • extensions on List for toJsonList()

Some details would need to be worked out, and it's possible there's already a way to do this today. Any thoughts or insights?

To be perfectly frank I assumed the external mapper was specifically to allow behavior like Swift's codable protocol, which specifies toJson on the instance and fromJson on the class. However, Dart interfaces cannot specify fromJson on the class, so we need to have a separate serializer class with toJson/fromJson on the instance, thus the assumption that this was core to your architectural decision.

Poking around a little bit it appears dart_mappable has an architecture more similar to built_value where there's a global god serializer, but that appears to require you to manually call PbDrillMapper.ensureInitialized() on every mapper class before any consumer code can ser/de arbitrary classes. An easy to construct mapper with instance methods that conforms to an interface should resolve this

Hi. If I get you right the mapper interfaces should actually have all you need.

However, dart_mappable doesn't seem to have these instance methods.

All generated mappers have:

  • encodeJson()
  • decodeJson()
  • encodeMap()
  • decodeMap()

This is important because you can then create generic tooling that takes in any serializable class instance and access serialization for it.

Yes. You could also just use a generic type parameter though:
MapperContainer.globals.fromJson<T>(...);

-> Assuming you have initialized the required mappers beforehand. (Read through https://pub.dev/documentation/dart_mappable/latest/topics/Generics-topic.html)

Poking around a little bit it appears dart_mappable has an architecture more similar to built_value where there's a global god serializer.

Yes (kinda). Although there are mapper instances, they are registered globally, and only one mapper per type can exist at any time. This is needed to handle nested properties. While you could explicitly call MyClassMapper.encode(MyClass()) that wouldn't define what mappers to use for any property of that class. So they are globally registered and identified by their targeted type.

but that appears to require you to manually call PbDrillMapper.ensureInitialized() on every mapper class before any consumer code can ser/de arbitrary classes

All generated mappers are singletons. MyClassMapper.ensureInitialized() will a) instantiate that singleton, b) register it globally, c) register any dependent mappers (for nested properties of that class, or subtypes, etc.) and d) return it.
Calling this manually is only needed when decoding using a generic type parameter as shown above. In all other cases (e.g. calling myClass.toJson() its done for you. (Also again its all written here: https://pub.dev/documentation/dart_mappable/latest/topics/Generics-topic.html)


Hope that clarifies things.

On a personal note, I'd love to be able to get rid of .ensureInitialized() and do this implicitly somehow, but thats not possible in Dart. Not to my knowledge (and extensive experimentation) that is. I would be very happy to be proven wrong though.

Screenshot 2024-06-15 at 11 08 09 AM

I cannot access that via constructor (as expected)

Screenshot 2024-06-15 at 11 08 54 AM

but it does look like I can get it from .ensureInitialized() (which I was not expecting, I figured that would return void)

Pardon my ignorance but why can't we generate a MyClassMapper() that, for the sake of example, implements Codec<dynamic, T>?

I suspect these would consume their field serializers internally instead of relying on this global stack

That would allow any package to expect a Codec<dynamic, T> and be able to consume any serialization package without having to import it

Swift has this concept with the Codable protocol and it means that any arbitrary package can accept any arbitrary serializable data model and be able to manage serialization / deserialization. Dart Mappable is the closest package so far to this unlock but it doesn't seem to go all the way

To answer your questions:

I cannot access that via constructor (as expected) but it does look like I can get it from .ensureInitialized() (which I was not expecting, I figured that would return void)

Its not meant to be accessed like this. Its a singleton that needs to be initialized. Hence the .ensureInitialized() which you can think of as a factory constructor for the singleton. (The naming is inspired by https://api.flutter.dev/flutter/widgets/WidgetsFlutterBinding/ensureInitialized.html. Similarly you don't create your own WidgetsFlutterBinding using a constructor)

Pardon my ignorance but why can't we generate a MyClassMapper() that, for the sake of example, implements Codec<dynamic, T>?

Darts Codec is not a nice api in my opinion.

I suspect these would consume their field serializers internally instead of relying on this global stack

All fine until you have classes using generics, polymorphism, dynamically typed properties etc. all the fun stuff dart_mappable supports.

Swift has this concept with the Codable protocol.

Swifts Codable cannot do what dart_mappable can. I like the the general api design and mappers may be influenced a bit by it, but thats all.

it means that any arbitrary package can accept any arbitrary serializable data model and be able to manage serialization / deserialization. Dart Mappable is the closest package so far to this unlock but it doesn't seem to go all the way

it doesn't seem to go all the way

I disagree / I still don't get what you think dart_mappable cannot do. Any package (that knows about dart_mappable) can accept any arbitrary data model and manage serialization.


Generally I think I can help you a lot more if you would share your use-case. I'd really like to help you or improve dart_mappable if there is something to improve, but I haven't read anything where so far dart_mappable would actually be lacking. It seems more like you are trying to use it in a way that its not supposed to be used and kinda ignore how its supposed to be used.


Adjusted from your initial example, how I would do it:

// Some library code, consumed by any app
class MyStorageBox<T> {

  MyStorageBox(this.key);

  final String key;

  Future<void> save(T value) async {
    final string = MapperContainer.globals.toJson<T>(value);
    await saveToDatabase(key, string);
  }

  Future<T> load() async {
    final string = getFromDatabase(key);
    return MapperContainer.globals.fromJson<T>(string);
  }
}


// App code, storing any mappable class
Future<void> main() async {
  PbDrillMapper.ensureInitialized();
  final storageBox = MyStorageBox('my-drill');
  await storageBox.save(PbDrill("drill-1", "40-yard dash"));
  final data = await storageBox.load();
}

You could even add some checks to help the user if MyStorageBox is supposed to be in a public package.

// Some library code, consumed by any app
class MyStorageBox<T> {

  MyStorageBox(this.key) {
    var mapper = MapperContainer.globals.get<T>();
    assert(mapper != null, "No mapper registered for type $T. Call '${T}Mapper.ensureInitialized()' before creating a 'MyStorageBox<$T>'.");
  }

  ...
}

Will close this issue. Feel free to reopen if you have more questions.