Code generator for generating type-safe mappers in dart, inspired by https://mapstruct.org/
- Add smartstruct as a dependency, and smartstruct_generator as a dev_dependency
- Create a Mapper class
- Annotate the class with @mapper
- Run the build_runner
- Use the generated Mapper!
Add smartstruct as a dependency, and the generator as a dev_dependency.
https://pub.dev/packages/smartstruct
dependencies:
smartstruct: [ version ]
dev_dependencies:
smartstruct_generator: [ version ]
# add build runner if not already added
build_runner:
Run the generator
dart run build_runner build
flutter packages pub run build_runner build
// or watch
flutter packages pub run build_runner watch
Create your beans.
class Dog {
final String breed;
final int age;
final String name;
Dog(this.breed, this.age, this.name);
}
class DogModel {
final String breed;
final int age;
final String name;
DogModel(this.breed, this.age, this.name);
}
To generate a mapper for these two beans, you need to create a mapper interface.
// dogmapper.dart
part 'dogmapper.mapper.g.dart';
@Mapper()
abstract class DogMapper {
Dog fromModel(DogModel model);
}
Once you ran the generator, next to your dog.mapper.dart a dog.mapper.g.dart will be generated.
dart run build_runner build
// dogmapper.mapper.g.dart
class DogMapperImpl extends DogMapper {
@override
Dog fromModel(DogModel model) {
Dog dog = Dog(model.breed, model.age, model.name);
return dog;
}
}
The Mapper supports positional arguments, named arguments and property access via implicit and explicit setters.
By default mapper generator works in case insensitivity manner.
class Source {
final String userName;
Source(this.userName);
}
class Target {
final String username;
Target({required this.username});
}
@Mapper()
abstract class ExampleMapper {
Target fromSource(Source source);
}
As you can see, classes above got different field's names (case) for username. Because mappers are case insensitive by default, those classes are correctly mapped.
class ExampleMapperImpl extends ExampleMapper {
@override
Target fromSource(Source source) {
final target = Target(username: source.userName);
return target;
}
}
To create case sensitive mapper, you can add param caseSensitiveFields to @Mapper annotation. Case sensitive mapper is checking field's names in case sensitive manner.
@Mapper(caseSensitiveFields: true)
abstract class ExampleMapper {
Target fromSource(Source source);
}
If some fields do not match each other, you can add a Mapping Annotation on the method level, to change the behaviour of certain mappings.
class Dog {
final String name;
Dog(this.name);
}
class DogModel {
final String dogName;
DogModel(this.dogName);
}
@Mapper()
class DogMapper {
@Mapping(source: 'dogName', target: 'name')
Dog fromModel(DogModel model);
}
In this case, the field dogName of DogModel will be mapped to the field name of the resulting Dog
class DogMapperImpl extends DogMapper {
@override
Dog fromModel(DogModel model) {
Dog dog = Dog(model.dogName);
return dog;
}
}
The source attribute can also be a Function. This Function will then be called with the Source Parameter of the mapper method as a parameter.
class Dog {
final String name;
final String breed;
Dog(this.name, this.breed);
}
class DogModel {
final String name;
DogModel(this.name);
}
@Mapper()
class DogMapper {
static String randomBreed(DogModel model) => 'some random breed';
@Mapping(source: randomBreed, target: 'breed')
Dog fromModel(DogModel model);
}
Will generate the following Mapper.
class DogMapperImpl extends DogMapper {
@override
Dog fromModel(DogModel model) {
Dog dog = Dog(model.dogName, DogMapper.randomBreed(model));
return dog;
}
}
Fields can be ignored, by specififying the ignore
attribute on the Mapping `Annotation``
class Dog {
final String name;
String? breed;
Dog(this.name);
}
class DogModel {
final String name;
final String breed;
DogModel(this.name, this.breed);
}
@Mapper()
class DogMapper {
@Mapping(target: 'breed', ignore: true)
Dog fromModel(DogModel model);
}
Will generate the following Mapper.
class DogMapperImpl extends DogMapper {
@override
Dog fromModel(DogModel model) {
Dog dog = Dog(model.name);
return dog;
}
}
Nested beans can be mapped, by defining an additional mapper method for the nested bean.
// nestedmapper.dart
class NestedTarget {
final SubNestedTarget subNested;
NestedTarget(this.subNested);
}
class SubNestedTarget {
final String myProperty;
SubNestedTarget(this.myProperty);
}
class NestedSource {
final SubNestedSource subNested;
NestedSource(this.subNested);
}
class SubNestedSource {
final String myProperty;
SubNestedSource(this.myProperty);
}
@Mapper()
abstract class NestedMapper {
NestedTarget fromModel(NestedSource model);
SubNestedTarget fromSubClassModel(SubNestedSource model);
}
Will generate the mapper
// nestedmapper.mapper.g.dart
class NestedMapperImpl extends NestedMapper {
@override
NestedTarget fromModel(NestedSource model) {
final nestedtarget = NestedTarget(fromSubClassModel(model.subNested));
return nestedtarget;
}
@override
SubNestedTarget fromSubClassModel(SubNestedSource model) {
final subnestedtarget = SubNestedTarget(model.myProperty);
return subnestedtarget;
}
}
Alternatively you can directly define the nested mapping in the source attribute.
class User {
final String username;
final String zipcode;
final String street;
User(this.username, this.zipcode, this.street);
}
class UserResponse {
final String username;
final AddressResponse address;
UserResponse(this.username, this.address);
}
class AddressResponse {
final String zipcode;
final StreetResponse street;
AddressResponse(this.zipcode, this.street);
}
class StreetResponse {
final num streetNumber;
final String streetName;
StreetResponse(this.streetNumber, this.streetName);
}
With this, you can define the mappings directly in the Mapping
Annotation
@Mapper()
abstract class UserMapper {
@Mapping(target: 'zipcode', source: 'response.address.zipcode')
@Mapping(target: 'street', source: 'response.address.street.streetName')
User fromResponse(UserResponse response);
}
Would generate the following mapper.
class UserMapperImpl extends UserMapper {
UserMapperImpl() : super();
@override
User fromResponse(UserResponse response) {
final user = User(response.username, response.address.zipcode,
response.address.street.streetName);
return user;
}
}
Lists will be mapped as new instances of a list, with help of the map method.
class Source {
final List<int> intList;
final List<SourceEntry> entryList;
Source(this.intList, this.entryList);
}
class SourceEntry {
final String prop;
SourceEntry(this.prop);
}
class Target {
final List<int> intList;
final List<TargetEntry> entryList;
Target(this.intList, this.entryList);
}
class TargetEntry {
final String prop;
TargetEntry(this.prop);
}
@Mapper()
abstract class ListMapper {
Target fromSource(Source source);
TargetEntry fromSourceEntry(SourceEntry source);
}
Will generate the Mapper
class ListMapperImpl extends ListMapper {
@override
Target fromSource(Source source) {
final target = Target(
source.intList.map((e) => e).toList(),
source.entryList.map(fromSourceEntry).toList());
return target;
}
@override
TargetEntry fromSourceEntry(SourceEntry source) {
final targetentry = TargetEntry(source.prop);
return targetentry;
}
}
The Mapper can be made a lazy injectable singleton, by setting the argument useInjection to true, in the Mapper Interface. In this case you also need to add the injectable dependency, as described here. https://pub.dev/packages/injectable
Make sure, that in the Mapper File, you import the injectable dependency, before running the build_runner!
// dogmapper.dart
import 'package:injectable/injectable.dart';
@Mapper(useInjectable = true)
abstract class DogMapper {
Dog fromModel(DogModel model);
}
// dogmapper.mapper.g.dart
@LazySingleton(as: DogMapper)
class DogMapperImpl extends DogMapper {
...
}
Generally you can use smartstruct with freezed.
One problem you will have to manually workaround is ignoring the freezed generated copyWith
method in the generated
mapper. The copyWith field is a normal field in the model / entity, and smartstruct does not have a way of knowing on
when to filter it out, and when not.
Imagine having the following freezed models.
@freezed
class Dog with _$Dog {
Dog._();
factory Dog(String name) = _Dog;
}
@freezed
class DogModel with _$DogModel {
factory DogModel(String name) = _DogModel;
}
Freezed will generate a copyWith
field for your Dog
and DogModel
.
When generating the mapper, you explicitly have to ignore this field.
@Mapper()
abstract class DogMapper {
@Mapping(target: 'copyWith', ignore: true)
Dog fromModel(DogModel model);
}
Will generate the mapper, using the factory constructor.
class DogMapperImpl extends DogMapper {
DogMapperImpl() : super();
@override
Dog fromModel(DogModel model) {
final dog = Dog(model.name);
return freezedtarget;
}
}
Static Methods in a Mapper Class will automatically be mapped with a static pendant in the generated mapper file.
class Dog {
final String name;
Dog(this.name);
}
class DogModel {
final String name;
DogModel(this.name);
}
@Mapper()
class DogMapper {
static Dog fromModel(DogModel model) => _$fromModel(model);
}
Will generate a mapper file providing the following static methods.
Dog _$fromModel(DogModel model) {
final dog = Dog(model.name);
return dog;
}
Smartstruct will generate a mapping method for every static method in the mapper class. This can and will get in conflict with the functional mapping, where we define certain functions for custom mapping.
Most of these custom mappings will be ignored by default, if it is a primitive type, a list, set, or an enum. In other cases the method has to be explicitly ignored, so it will not generate a mapping method, where I do not want one.
class Breed {
final String name;
Breed(this.name);
}
class Dog {
final String name;
final Breed breed;
Dog(this.name, this.breed);
}
class DogModel {
final String name;
DogModel(this.name);
}
@Mapper()
class DogMapper {
@IgnoreMapping()
static Breed customBreed() => Breed('Chihuahua');
@Mapping(target: 'breed', source: customBreed)
static Dog fromModel(DogModel model) => _$fromModel(model);
}
Smartstruct can not differentiate between if he should generate a mapping method for customBreed
or not.
So we have to tell him explicitly to not generate a mapping method by adding the Annotation @IgnoreMapping
Alternatively you can set generateStaticProxy
to true
in the Mapping Annotation, to generate a Mapper Proxy
implementation for your static methods.
class Dog {
final String name;
Dog(this.name);
}
class DogModel {
final String name;
DogModel(this.name);
}
@Mapper(generateStaticProxy = true)
class DogMapper {
Dog fromModel(DogModel model);
}
Will generate the following mapper.
class DogMapperImpl extends DogMapper {
DogMapperImpl() : super();
@override
Dog fromModel(DogModel model) {
final dog = Dog(model.name);
return dog;
}
}
class DogMapper$ {
static final DogMapper mapper = DogMapperImpl();
static Dog fromModel(DogModel model) =>
mapper.fromModel(model);
}
Please refer to the example package, for a list of examples and how to use the Mapper Annotation.
You can always run the examples by navigating to the examples package and executing the generator.
$ dart pub get
...
$ dart run build_runner build
Feel free to open a Pull Request, if you'd like to contribute.
Or just open an issue, and i do my level best to deliver.