dart_mappable 1.0.0-dev.3 copy "dart_mappable: ^1.0.0-dev.3" to clipboard
dart_mappable: ^1.0.0-dev.3 copied to clipboard

outdated

Serialize / deserialize dart objects effortlessly, without any boilerplate code or compromises.

Imagine a mapping & serialization package with:

  • NO nasty boilerplate code
  • NO minified/uglified generated files
  • NO workarounds
  • NO compromises

while being able to

  • decode & encode json
  • come with built-in type & null-safety
  • be fully configurable
  • support custom types, generics, polymorphism and more.

Sounds too good to be true? Not anymore.

Dart Mappable #

Have a look at the example!

This package is still in active development. If you have any feedback or feature requests, write me and issue on github.

TODOs #

  • Choose properties for toString / equals

Get Started #

First, add dart_mappable as a dependency, and build_runner as a dev_dependency.

flutter pub add dart_mappable
flutter pub add build_runner --dev

Next, create a build.yaml in the root directory of your package and add this snippet:

targets:
  $default:
    builders:
      dart_mappable:
        generate_for:
          - lib/main.dart # modify this if you have a different entry point

Then annotate your classes that you want to use with @MappableClass():

@MappableClass()
class MyClass {
  ...
}

In order to generate the serialization code, run the following command:

pub run build_runner build

You'll need to re-run code generation each time you are making changes to your annotated classes. During development, you can use watch to automatically watch your changes like this

pub run build_runner watch

This will generate a .mapper.g.dart file for each of your entry points specified in the build.yaml file. Last step is to import the generated files wherever you want / need them.

How to use #

The recommended way to use dart_mappable is to annotate your model classes with @MappableClass() and your enums with @MappableEnum(). Each annotation has a set of properties to configure the generated code.

@MappableClass()
class MyClass { ... }

@MappableEnum()
enum MyEnum { ... }

The properties are documented here for @MappableClass() and here for @MappableEnum().

For deserialization, dart_mappable will use the first available constructor of a class, but you can use a specific constructor using the @MappableConstructor() annotation.

@MappableClass()
class MyClass {
  MyClass(); // Don't use this
  
  @MappableConstructor()
  MyClass.special(); // Use this
}

You can also annotate a single field or constructor parameter of a class using @MappableField() to set a specific json key or add hooks.

@MappableClass()
class MyClass {
  MyClass(this.value);

  @MappableField(key: 'my_key')
  String value;
}

Note: This can only be used on a field if it is directly assigned as a constructor parameter (MyClass(this.myField)). Setting this annotation on any other field will have no effect. (See Utilize Constructors for an explanation why this is.)

You can add the @MappableLib() annotation to your library statement to set a default configuration for all included classes and enums, e.g. the case style.

@MappableLib(caseStyle: CaseStyle.camelCase) // will be applied to all classes
library models;

@MappableClass() // no need to set caseStyle here
class MyClass {
  ...
}

You can also use the include and exclude property to add any or all classes and enums in a library. Using exclude will result in all classes in a library except the specified ones to be included. Classes or enums specified using those properties do not need to have the @MappableClass() or @MappableEnum() annotation.

@MappableLib(include: [MyClass])
library models;

// will be used even without annotation
class MyClass {
  ...
}

The @MappableLib() annotation can also be used on import or export statements. This is especially useful when you want to use classes from an external package, since you can't modify its code and add the necessary annotations to its classes and enums.

@MappableLib(include: [ClassA, ClassB]) // ClassA and ClassB are defined in external_package
import 'package:external_package/external_package.dart';

...

Lastly, you can define custom mappers using the '@CustomMapper()' annotation.


Here are again all six annotations that you can use in your code:

  1. @MappableClass() can be used on a class to specify options like the caseStyle of the json keys, whether to ignore null values, or hooks.
  2. @MappableEnum() can be used on an enum to specify the caseStyle of the stringified enum values, or the defaultValue.
  3. @MappableConstructor() can be used on a constructor to mark this to be used for decoding. It has no properties.
  4. @MappableField() can be used on a constructor parameter or a field to specify a json key to be used instead of the field name, or hooks.
  5. @MappableLib() can be used on a library statement or import / export statement to set a default configuration for the annotated library or include / exclude classes.
  6. @CustomMapper() can be used to specify a custom mapper, used alongside the generated mappers. See Custom Mappers for a details explanation.

For an overview of all the annotation properties, head to the Api Documentation.

Global options #

Additionally to using the @MappableClass() and @MappableLib() annotations for configuration, you can also define a subset of their properties as global options in the build.yaml file:

targets:
  $default:
    builders:
      dart_mappable:
        generate_for:
          - lib/main.dart
          - lib/models.dart # multiple independent entry files
        options:
          # the case style for the map keys, defaults to 'none'
          caseStyle: none # or 'camelCase', 'snakeCase', etc.
          # the case style for stringified enum values, defaults to 'none'
          enumCaseStyle: none # or 'camelCase', 'snakeCase', etc.
          # if true removes all map keys with null values
          ignoreNull: false # or true
          # used as property name for type discriminators
          discriminatorKey: type
          # used to specify which methods to generate (all by default)
          generateMethods: [decode, encode, copy, stringify, equals]

Mapper Interface #

dart_mappable will generate a main Mapper class that provides access to all important methods:

  • Mapper.fromValue<T>(encoded) will take an encoded object and return a decoded object of type T.
  • Mapper.fromMap<T>(encoded) and Mapper.fromJson<T>(encoded) internally use fromValue but expect a Map<String, dynamic or String respectively.
  • Mapper.toValue(decoded) will take any object and return an encoded object of type dynamic (usually a Map<String, dynamic for custom classes).
  • Mapper.toMap(decoded) and Mapper.toJson(encoded) internally use toValue but will return a Map<String, dynamic> or String respectively.
  • Mapper.isEqual(a, b) will compare two objects for equality.
  • Mapper.asString(o) will return a string representation of an object.

Also for each included class there are .toMap() and toJson() extension methods generated, which internally call Mapper.toMap() and Mapper.toJson() respectively.

In order for classes to support comparison via == as well as a custom toString() implementation, you have to use the Mappable mixin on those classes.

Generation Methods #

This package can generate a few different sets of methods, which can be activated or deactivated. This makes sure that only code is generated that you actually need. By default, all methods are generated for each class.

You can set the generateMethods property to specify which methods to generate. Either in the global options using a List of Strings, or with a annotation using the GenerateMethods flags. The following methods are supported:

  • decode: Will generate code used by Mapper.fromJson, Mapper.fromMap and Mapper.fromValue.
  • encode: Will generate code used by Mapper.toJson, Mapper.toMap and Mapper.toValue as well as the extension methods toJson and toMap.
  • copy: Will generate the extension method copyWith.
  • stringify: Will generate code used by Mapper.asString or when using the Mappable mixin's toString override.
  • equals: Will generate code used by Mapper.isEqual or when using the Mappable mixin's == or hashCode overrides.

When using annotations, you can specify multiple methods using the bitwise-or operator like this: @MappableClass(generateMethods: GenerateMethods.copy | GenerateMethods.equals | GenerateMethods.stringify).

Utilize Constructors #

There exist a lot of custom use cases, when it comes to mapping any object. Common ones include renaming fields, ignoring fields, computing values, or custom date or number formats. Instead of providing custom tailored serialization options for each use-case, this package utilizes the power of constructor arguments to cover all of them. Thereby, you keep full control over your models, while writing pure and easy dart code.

How does that work exactly: When analysing your code, dart_mappable never looks at the fields of your model, but rather only at the constructor arguments; What you do with them - writing to fields, renaming, etc. - is up to your model's implementation. To illustrate this, here are some examples for the above mentioned use cases:

class Person {
  String name;
  int age;
    
  // basic example, nothing special going on
  Person.base(this.name, this.age);

  // renamed argument, will be 'years': ... in json
  Person.renamed(this.name, int years) : age = years;
  // when renaming arguments, make sure to always have a matching getter for serialization *
  int get years => age;
  
  // ignores the age field completely
  Person.ignored(this.name);
 
  // computed name value
  Person.computed(String firstName, String lastName, this.age) : name = '$firstName $lastName';
  // again: have matching getters for all arguments, reversing the computed value *
  String get firstName => name.split(' ')[0];
  String get lastName => name.split(' ')[1];
}

class Event {
  DateTime date;

  // custom formatting as unix timestamp
  Event.format(int timestamp) : date = DateTime.fromMillisecondsSinceEpoch(timestamp);
  int get timestamp => date.millisecondsSinceEpoch;
}

*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.

Remember: dart_mappable will always use the first constructor it sees, but you can use a specific constructor using the @MappableConstructor() annotation.

Case Styles #

You can specify the case style for the json keys and your stringified enum values. Choose one of the existing styles or specify a custom one.

Currently supported are:

none / unmodified: keeps your field names as the are (default)
camelCase: myFieldName -> myFieldName (dart default)
pascalCase: myFieldName -> MyFieldName
snakeCase: myFieldName -> my_field_name
paramCase: myFieldName -> my-field-name
lowerCase: myFieldName -> myfieldname
upperCase: myFieldName -> MYFIELDNAME

You can also specify a custom case style using the custom(ab,c) syntax.

  • The letters before the comma define how to transform each word of a field name. They can be either l for lowerCase, u for upperCase, or c for capitalCase. When using only one letter, it is applied to all words. When using two letters, the first one is applied to only the first word and the second one to all remaining words.
  • The one letter after the comma defines the separator between each word, like _ or -. This can be any character or empty.

Here are some examples that can be achieved using this syntax:

custom(u,_): myFieldName -> MY_FIELD_NAME
custom(uc,+): myFieldName -> MY+Field+Name
custom(cl,): myFieldName -> Myfieldname

Lists, Sets and Maps #

dart_mappable support Lists, Sets and Maps out of the box, without any special syntax, workarounds or hacks. Just use Mapper.fromJson as you normally would:

class Dog with Mappable {
  String name;
  Dog(this.name);
}

class Box<T> with Mappable {
  T content;
  Box(this.content);
}

void main() {

  // simple list
  List<int> nums = Mapper.fromJson('[2, 4, 105]');
  print(nums); // [2, 4, 105]

  // set of objects
  Set<Dog> dogs = Mapper.fromJson('[{"name": "Thor"}, {"name": "Lasse"}, {"name": "Thor"}]');
  print(dogs); // {Dog(name: Thor), Dog(name: Lasse)}

  // or more complex lists, like generics
  List<Box<double>> boxes = Mapper.fromJson('[{"content": 0.1}, {"content": 12.34}]');
  print(boxes); // [Box(content: 0.1), Box(content: 12.34)]
}

There is also the Mapper.fromIterable method. This can be used if you already have a list of dynamic objects instead of the raw json string. Additionally this can get handy to decode a dynamic list of partly-encoded values:

List<double> myNumbers = Mapper.fromIterable([2.312, '1.32', 500, '1e4']);
print(myNumbers); // [2.312, 1.32, 500.0, 10000.0]

Non-Trivial Maps #

We also support decoding of non-trivial maps. Although the use-cases might be rare, you can decode to something other than a map of string keys like this:

var encodedMap = {
  {'name': 'Bonny'}: 1,
  {'name': 'Clyde'}: 5,
};

Map<Dog, int> treatsPerDog = Mapper.fromValue(encodedMap);
print(treatsPerDog[Dog('Clyde')]!); // 5

var myMap = Mapper.toValue(treatsPerDog);
print(myMap); // {{name: Bonny}: 1, {name: Clyde}: 5}

Make sure to mixin Mappable on your key class, in order to enable easy property access

Since json only supports string keys, we can't do Mapper.fromJson or Mapper.toJson on these maps. You would have to decode / encode your keys and values separately.

CopyWith #

dart_mappable can generate a powerful copyWith method for your classes. It supports assigning null as well as chained deep copies.

class Person {
  String name;
  int? age;
    
  Person(this.name, this.age);
}

void main() {
  var person = Person('Tom', 20);

  // `age` not passed, its value is preserved
  print(person.copyWith(name: 'Max')); // Person(name: Max, age: 24)
  // `age` is set to `null`
  print(person.copyWith(age: null)); // Person(name: Tom, age: null)
}

Deep Copy #

When having complex nested classes, this syntax can get quite verbose. Therefore this package provides a special syntax for nested classes, similar to freezed.

Consider the following classes:

class Person {
  String name;

  Person(this.name);
}

class Company {
  Person manager;
  List<Person> employees;
  Company(this.manager, this.employees);
}

void main() {
  var company = Company(Person('Anna'), [Person('Max'), Person('Tom')]);
  
  // access nested object using the 'dot' syntax
  print(company.copyWith.manager(name: 'Laura')); 
  // prints: Company(manager: Person(name: 'Laura'), ...)
  
  // this also works with lists
  print(company.copyWith.employees.at(0)(name: 'John')); 
  // prints: Company(..., employees: [Person(name: 'John), Person(name: 'Tom')])
  
  // you can also use 'apply' with a custom function to transform a value
  print(company.copyWith.manager.apply((manager) => Person(manager.name.toUpperCase())));
  // prints: Company(manager: Person(name: 'LAURA'), ...)
}

When working with Lists or Maps and copyWith, there are different methods you can use to access, add, remove or filter elements. The complete interfaces are documented

Polymorphism and Discriminators #

A common pattern that you might want to use for your classes is polymorphism. As a simple example see the classes below.

abstract class Animal with Mappable {
  String name;
  Animal(this.name);
}

class Cat extends Animal {
  String color;
  Cat(String name, this.color) : super(name);
}

class Dog extends Animal {
  int age;
  Dog(String name, this.age) : super(name);
}

Now when we want to encode a Home object, the pet property can either be a Cat or a Dog. To make sure that this information isn't lost when converting to json, we need to add a discriminator property, that keeps track of the specific type of the pet.

By default no discriminator is applied, but you can change this by setting the discriminatorKey annotation property or in the build configuration. The value of this property will default to the name of the class, but you can change this as well with the discriminatorValue annotation property. Make sure to specify the discriminatorKey property globally or on the base class - Animal in our example - and the discriminatorValue property on each of the child classes - Cat and Dog in our case.


@MappableClass(discriminatorKey: 'type')
abstract class Animal with Mappable {
  ...
}

...

void main() {
  // encode a polymorphic class
  String catJson = Mapper.toJson(Cat('Judy', 'Black'));
  print(catJson); // {"name":"Judy","color":"Black","type":"Cat"}

  Animal myPet = Mapper.fromJson(catJson); // implicit decoding as an 'Animal'
  print(myPet.runtimeType); // Cat

  // explicit decoding also works as usual without a discriminator
  Cat myCat = Mapper.fromJson('{"name": "Kitty", "color": "Brown"}');
  print(myCat.name); // Kitty
}

Null and Default Values #

There are two additional cases that you might want to cover. Either your discriminator property is null, or some other value, that you did not specify.

  1. For the null case, you can explicitly set the discriminatorValue property to null and this will work as expected.
  2. For the other case, you can specify a fallback class to be used whenever we come across an unknown discriminator value. On this class set the discriminatorValue to MappableClass.useAsDefault.
@MappableClass(discriminatorValue: null)
class NullAnimal extends Animal {
  NullAnimal(String name) : super(name);
}

@MappableClass(discriminatorValue: MappableClass.useAsDefault)
class DefaultAnimal extends Animal {
  String type;
  DefaultAnimal(String name, this.type) : super(name);
}

void main() {
  // decode json with the discriminator set to null (same as missing property)
  Animal animal1 = Mapper.fromJson('{"name": "Scar", "type": null}');
  print(animal1.runtimeType); // NullAnimal

  // decode json with unknown discriminator value
  Animal animal2 = Mapper.fromJson('{"name": "Balu", "type": "Bear"}');
  print(animal2.runtimeType); // DefaultAnimal
  print(animal2.type); // Bear
}

Encoding / Decoding Hooks #

Hooks provide a way to hook into the encoding and decoding process for a class or single field. When using hooks, you have the possibility to inspect and modify values before and after they are encoded or decoded.

To use hooks, create a custom class extending the MappingHooks class, and set this as the hooks argument of either the MappableClass or MappableField annotation like this:

class GameHooks extends MappingHooks {
  const GameHooks();
}

class PlayerHooks extends MappingHooks {
  const PlayerHooks();
}

@MappableClass(hooks: GameHooks())
class Game {
  @MappableField(hooks: PlayerHooks())
  Player player;

  Game(this.player);
}

class Player {
  String id;
  Player(this.id);
}

Now, whenever an instance of Game is encoded or decoded, the GameHooks will be applied to the class and the PlayerHooks will be applied to the player field. Inside your hooks class, you have four methods that you can override:

  • dynamic beforeDecode(dynamic value)
  • dynamic afterDecode(dynamic value)
  • dynamic beforeEncode(dynamic value)
  • dynamic afterEncode(dynamic value)

Each method takes a dynamic value and returns an optionally modified value.

Tip: If the beforeDecode hook already returns an instance of the target type, the normal decoding logic is skipped. The same is true for the beforeEncode hook. This gives you the possibility to use different custom mappers on the same type, especially on a field-by-field level.

A simple use-case for this would be to modify the input json before an object is decoded. In the example below, the player field can either be a full json object, or a single string. The string would then be treated as the id of the player.

class PlayerHooks extends MappingHooks {
  const PlayerHooks();

  @override
  dynamic beforeDecode(dynamic value) {
    if (value is String) {
      return {'id': value};
    }
    return value;
  }
}

...

void main() {
  // This works as usual
  Game game = Mapper.fromJson('{"player": {"id": "Tom"}}');
  print(game.player.id); // Tom;

  // Special case: 'player' is a string instead of an object
  Game game2 = Mapper.fromJson('{"player": "John"}');
  print(game.player.id); // John
}

Additionally, it is important to note that field- and class-hooks are inherited by any subclasses. When both the superclass and the subclass define class-hooks, both are applied in the following order: super.beforeDecode -> sub.beforeDecode -> decode -> sub.afterDecode -> super.afterDecode. For field-hooks, only the sub-class-hook is applied.

Chaining multiple MappingHooks #

You can chain and apply multiple hooks using the ChainedHooks class like this: @MappableClass(hooks: ChainedHooks([ MyHooks1(), MyHooks2(), ... ])).

The hooks are applied for encoding and decoding in nested order: first.beforeDecode -> second.beforeDecode -> ... -> decode -> ... -> second.afterDecode -> first.afterDecode.

Unmapped Properties #

A frequently needed use-case for hooks is to catch additional, unmapped properties from json when decoding an object. Because of this, we provide a ready-to-use MappingHook called UnmappedPropertiesHooks.

To use this hook, define a Map<String, dynamic> field in your class, and provide it's name to the UnmappedPropertiesHooks constructor. Be aware that you have to provide the matching json key of the field (after applying the case style, etc.) instead of the dart field name.

@MappableClass(hooks: UnmappedPropertiesHooks('unmapped_props'))
class Game {
  String id;
  Map<String, dynamic> unmappedProps;

  Game(this.id, this.unmappedProps);
}

void main() {
  Game game = Mapper.fromJson('{"id": 1, "type": "pacman", "score": 100}');
  print(game.id); // 1
  print(game.unmappedProps); // {type: pacman, score: 100}
}

Custom Mappers #

You can create custom mappers to serialize / deserialize custom types that are not part of the generated code like this:

@CustomMapper()
class CustomStringMapper extends SimpleMapper<String> {
  @override
  String decode(dynamic value) {
    return (value as String).substring(1);
  }
  
  @override
  dynamic encode(String self) {
    return '_$self';
  }
}

There are also additional methods you can override, like stringify, or equals. This will enable Mapper.isEqual and Mapper.asString on this type.

Using the @CustomMapper() annotation, this mapper will automatically be registered and used by the library. Or, you can instead register a custom mapper dynamically using Mapper.use<MyClass>(MyCustomMapper()) and unregister using Mapper.unuse<MyClass>(). This might come in handy if you want to switch between different custom mappers for the same type. Also, be aware that you can also unuse() (and replace) any mappers, both custom, generated, and mappers of primitive types.

Generic Custom Types #

When dealing with generic types, we need a more sophisticated syntax for decoding. Instead of extending SimpleMapper you have to extend BaseMapper when wanting to decode a generic class. Next, instead of overriding the decode function, specify a decoder getter, which must be a function that can accept up to three additional type arguments.

You also need to construct a typeFactory as shown below.

class GenericBox<T> {
  T content;
  GenericBox(this.content);
}

@CustomMapper()
class CustomGenericMapper extends BaseMapper<GenericBox> { // only use the base type here

  @override
  Function decoder = <T>(dynamic value) { // specify the decoder as a generic function
    return GenericBox<T>(Mapper.fromValue<T>(value)); // use the type parameter in your decoding logic
  };
  
  @override
  Function encoder = (GenericBox self) { // no need for type parameters here
    return Mapper.toValue(self.content);
  };

  // in case of generic types, we also must specify a type factory. This is a special type of 
  // function used internally to construct generic instances of your type.
  // specify any type arguments in alignment to the decoder function
  @override
  Function get typeFactory => <T>(f) => f<GenericBox<T>>();
}

Custom Iterables and Maps #

For special Iterable and Map types, you can of course specify CustomMappers as described in the previous section. However, we provide ready-to-use IterableMapper and MapMapper to make your life a little bit easier:

For both you have to provide

  1. a factory function, which converts a generic iterable / map to your special implementation,
  2. a type factory, similar to the one used in generic custom mappers.
Mapper.use(IterableMapper<HashSet>(
  <T>(Iterable<T> i) => HashSet.of(i),
  <T>(f) => f<HashSet<T>>(),
));

Mapper.use(MapMapper<HashMap>(
  <K, V>(Map<K, V> m) => HashMap.of(m),
  <K, V>(f) => f<HashMap<K, V>>(),
));

HashSet<Brand> brands = Mapper.fromJson('["toyota", "audi", "audi"]');
print(brands); // {Brands.Toyota, Brands.Audi}

Supported Packages #

This package aims to be compatible with other code-generation packages. Check the examples directory for some common use-cases.

Freezed #

Freezed is a "code generator for unions/pattern-matching/copy"; With this package, it is easy to create union or sealed classes.

Here is a simple example taken from their documentation:

@freezed
class Union with _$Union {
  const factory Union(int value) = Data;
  const factory Union.loading() = Loading;
  const factory Union.error([String? message]) = ErrorDetails;
}

To make it compatible with dart_mappable, just add your @MappableClass annotations to both the parent class, and all factory constructors, as if they were the child classes. For a description of the discriminatorKey and discriminatorValue properties head up to the Polymorphism and Discriminators section. You can also add the @MappableField() annotation to any of the fields.

@freezed
@MappableClass(discriminatorKey: 'type')
class Union with _$Union {
  @MappableClass(discriminatorValue: 'data')
  const factory Union.data(@MappableField(key: 'mykey') int value) = Data;
  @MappableClass(discriminatorValue: 'loading')
  const factory Union.loading() = Loading;
  @MappableClass(discriminatorValue: 'error')
  const factory Union.error([String? message]) = ErrorDetails;
}

This will now allow you to use this and the resulting Data, Loading and ErrorDetails classes as usual:

void main() {
  var data = Union.data(42);

  var dataJson = data.toJson();
  print(dataJson); // {"mykey":42,"type":"data"}

  var parsedData = Mapper.fromJson<Union>(dataJson);
  print(parsedData); // Union.data(value: 42)
}

For the full example and generated files, check out the examples/example_freezed directory.

321
likes
0
pub points
97%
popularity

Publisher

verified publisherschultek.de

Serialize / deserialize dart objects effortlessly, without any boilerplate code or compromises.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

analyzer, build, collection, path, source_gen, type_plus

More

Packages that depend on dart_mappable