Mapping data from an API to a database. Serializing objects to persistent storage. In most data heavy applications we are constantly converting from one type of data to another.
REST apis are beautiful and easily parsed, but even with pure REST apis there is hardly ever a clean 1-on-1 mapping between what you get from the server and what you process inside the app.
We've been using some simple mapping mechanisms that allow us to easily and declaratively map data from one format to another.
We'll have to add some getting started documentation and it's far from ready, but we thought we'd share it anyway because it may benefit others.
EFDataMappingKit has a Podspec available, so if you use CocoaPods add
pod 'EFDataMappingKit'
to your Podfile.
Documentation is available here.
Still in early development, but a nice little code generator is available here. It takes your JSON and creates basic mappings for you.
Let's take this example of JSON describing a user:
{
"user_id": 42,
"username": "john.doe",
"messages": [
{
"message_id": 1,
"published_at": "2014-02-13",
"read": true,
"text": "FYI, tomorrow night I am hanging out with the guys!"
},
{
"message_id": 2,
"published_at": "2014-02-14",
"read": false,
"text": "Just kidding, romantic dinner by candle light awaits you!"
},
{
"message_id": 3,
"published_at": "2014-02-15",
"read": false,
"text": "Darling?!"
}
],
"website": "http://www.example.com"
}
and map it to our MYUser
and MYMessage
objects which have these interfaces:
@interface MYUser : NSObject
@property (nonatomic, assign) NSUInteger userId;
@property (nonatomic, copy) NSString *username;
@property (nonatomic, strong) NSArray *messages;
@property (nonatomic, strong) NSURL *website;
@end
@interface MYMessage : NSObject
@property (nonatomic, assign) NSUInteger messageId;
@property (nonatomic, strong) NSDate *publicationDate;
@property (nonatomic, assign) BOOL read;
@property (nonatomic, copy) NSString *text;
@end
You can use the shared instance:
EFMapper *mapper = [EFMapper sharedInstance];
but you don't have too:
EFMapper *mapper = [[EFMapper alloc] init];
A mapping describes how a value retrieved from an external source should be mapped on an internal entity.
For each mapping you need to specify at least the externalKey
and internalKey
(or use key
to set both the same) and you need to specify what kind of value you expect. For primitives such as BOOL
, int
, CGFloat
use NSNumber
.
We recommend creating a category on your entity class and adding + mappings
method there.
In MYUser (Mappings)
implementation:
+ (NSArray *)mappings {
return @[
[EFMapping mapping:^(EFMapping *m) {
m.internalClass = [NSNumber class];
m.externalKey = @"user_id";
m.internalKey = @"userId";
m.requires = [EFRequires exists];
}],
[EFMapping mapping:^(EFMapping *m) {
m.internalClass = [NSString class];
m.key = @"username";
m.requires = [EFRequires exists];
}],
[EFMapping mappingForArray:^(EFMapping *m) {
m.internalClass = [MYMessage class];
m.key = @"messages";
}],
[EFMapping mapping:^(EFMapping *m) {
m.internalClass = [NSURL class];
m.key = @"website";
m.transformationBlock = ^id(id value, BOOL reverse) {
if (reverse) {
return [(NSURL *)value absoluteString];
} else {
return [NSURL URLWithString:(NSString *)value];
}
};
}]
];
}
In MYMessage (Mappings)
implementation:
+ (NSArray *)mappings {
return @[
[EFMapping mapping:^(EFMapping *m) {
m.internalClass = [NSNumber class];
m.externalKey = @"message_id";
m.internalKey = @"messageId";
m.requires = [EFRequires exists];
}],
[EFMapping mapping:^(EFMapping *m) {
m.internalClass = [NSDate class];
m.externalKey = @"published_at";
m.internalKey = @"pulicationDate";
NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
[dateFormatter setDateFormat:@"yyyy-MM-dd"];
m.formatter = dateFormatter;
}],
[EFMapping mappingForNumberWithKey:@"read"],
[EFMapping mappingForStringWithKey:@"text"]
];
}
Use formatter
, transformer
and transformBlock
if you need make some changes to a value. This works great with NSDateFormatter
for dates. You can further declare your requirements for the value by setting one or more EFRequires
on the requires
property.
You register an array of EFMapping
objects for each entity class.
EFMapper *mapper = [EFMapper sharedInstance];
[mapper registerMappings:[MYUser mappings] forClass:[MYUser class]];
[mapper registerMappings:[MYMessage mappings] forClass:[MYMessage class]];
You apply your values either to an already existing instance or you can ask for a new object to be initialized. Before applying the values, the mapper will validate the values and let you know about any issues.
To apply to an existing object:
EFMapper *mapper = [EFMapper sharedInstance];
NSDictionary *incomingValues = ...;
MYUser *existingObject = ...;
NSError *error;
if (![mapper setValues:incomingValues onObject:existingObject error:&error]) {
NSLog(@"Could not set values due to error: %@", EFPrettyMappingError(error));
}
To create a new object:
EFMapper *mapper = [EFMapper sharedInstance];
NSDictionary *incomingValues = ...;
NSError *error;
MYUser *newObject = [mapper objectOfClass:[MYUser class] withValues:incomingValues error:&error]);
if (!newObject) {
NSLog(@"Could not create new object due to error: %@", EFPrettyMappingError(error));
}
Use formatter
, transformer
and transformBlock
if you need make some changes to a value. This works great with NSDateFormatter
for dates.
On the requires property of a mapping you can either set one EFRequires
instances, or an array of EFRequires
instances. All EFRequires
should pass for a value to be considered. You can create more complex requirements using +[EFRequires either:or:]
and +[EFRequires not:]
.
An array or dictionary with values.
Registering initializers is optional. If no initializer is specified an object is created by calling alloc
and init
on it. It is also valid to return an existing object if you wish to avoid having multiple instances of the same entity. Beware of introducing retain loops if you take this approach.
EFMapper *mapper = [[EFMapper alloc] init];
[mapper registerInitializer:^id(__unsafe_unretained Class aClass, NSDictionary *values) {
NSString *username = values[@"user_name"];
return [[aClass alloc] initWithUsername:username];
} forClass:[MYUser class]];
In some cases you may have special needs for a specific class. You can register custom mappers
EFDataMappingKit can do a few more things to make use of the mappings.
You can use the mappings also to quickly add the NSCoding
protocol to your objects.
- (id)initWithCoder:(NSCoder *)aDecoder {
self = [super init];
if (self) {
[[EFMapper sharedInstance] decodeObject:self withCoder:aDecoder];
}
return self;
}
- (void)encodeWithCoder:(NSCoder *)aCoder {
[aCoder encodeInteger:0 forKey:@"version"];
[[EFMapper sharedInstance] encodeObject:self withCoder:aCoder];
}
These methods should only be called once during the encoding and decoding processes, so if the super class of your object already calls the encoding and decoding methods of EFMapper
, don't call them again in your subclass.
You can turn your entity back into a dictionary/JSON representation.
EFMapper *mapper = [EFMapper sharedInstance];
MYUser *userObject = ...;
NSDictionary *userDictionaryRepresentation = [mapper dictionaryRepresentationOfObject:userObject];
By default all keys are returned, though you can limit these to a subset using -[EFMapper registerDictionaryRepresentationKeys:forClass:]
.