DataMapper
Introduction
DataMapper is a framework for safe deserialization/serialization of objects from/to different data representation standards (as of now we support JSON but others can be added easily).
Among its advantages belongs:
- Easy to use API
- Compile time safety (as much as possible)
- Support for custom Serializers (allows you to simply change data representation format by implementing one class)
- Polymorph
- Thread safety (depends on your usage)
- Support for one direction use (if you don't need the other one, you don't have to implement it)
Changelog
List of all changes and new features can be found here.
Requirements
- Swift 4
- iOS 8+
Installation
CocoaPods
DataMapper is available through CocoaPods. To install it, simply add the following line to your test target in your Podfile:
pod "DataMapper"
This will automatically include every subspec (Core and all Serializers).
If you want DataMapper without serializers (you have your own implementation), use:
pod "DataMapper/Core"
Each implementation of serializer has its own subspec. For example:
pod "DataMapper/JsonSerializer"
These subspecs have Core as dependency, so there is no need to specify it explicitly.
Usage
Below is complete list of all features this library offers and how to use them. Some examples of usage can be found in tests.
Used terminology:
- mapping - either deserialization or serialization
- Map protocol -
Deserializable
,Serializable
orMappable
Quick overview
/*
[{
"number": 1,
"text": "A"
}, {
"number": 2,
"text": "B"
}]
*/
let inputData: NSData = ... // Some data in JSON to be deserialized.
let objectMapper = ObjectMapper()
let serializer = JsonSerializer()
// Deserialization
let type = serializer.deserialize(inputData)
let objects: [MyObject]? = objectMapper.deserialize(type)
... // Do some stuff with objects.
// Serialization
let changedType = objectMapper.serialize(objects)
let outputData = serializer.serialize(changedType)
// Can be deserilized and serialized.
struct MyObject: Mappable {
var number: Int?
var text: String?
init(_ data: DeserializableData) throws {
try mapping(data)
}
mutating func mapping(_ data: inout MappableData) throws {
data["number"].map(&number)
data["text"].map(&text)
}
}
// Can be only deserialized.
struct MyDeserializableObject: Deserializable {
let number: Int?
let text: String?
init(_ data: DeserializableData) throws {
number = data["number"].get()
text = data["text"].get()
}
}
// Can be only serialized.
struct MySerializableObject: Serializable {
let number: Int?
let text: String?
init(number: Int?, text: String?) {
self.number = number
self.text = text
}
func serialize(to data: inout SerializableData) {
data["number"].set(number)
data["text"].set(text)
}
}
SupportedType
SupportedType
creates intermediate level between ObjectMapper and Serializers. It is an enum like structure (for performance reasons implemented using class) representing basic data types (null
, string
, bool
, int
, double
, array
, dictionary
). Each type has associated property which return its value if it is correct type or nil otherwise. With small exception for null
whose property is named isNull
and returns Bool
.
extension SupportedType {
var isNull: Bool
var string: String?
var bool: Bool?
var int: Int?
var double: Double?
var array: [SupportedType]?
var dictionary: [String: SupportedType]?
mutating func addToDictionary(key: String, value: SupportedType)
}
For example:
let type: SupportedType = .string("A")
type.string // "A"
type.number // nil
addToDictionary
adds the key value pair to the dictionary. If the current type is not a dictionary, then it is replaced with a new dictionary.
SupportedType
can be created using these static methods:
extension SupportedType {
static var null: SupportedType
static func string(_ value: String) -> SupportedType
static func bool(_ value: Bool) -> SupportedType
static func int(_ value: Int) -> SupportedType
static func double(_ value: Double) -> SupportedType
static func array(_ value: [SupportedType]) -> SupportedType
static func dictionary(_ value: [String: SupportedType]) -> SupportedType
static func intOrDouble(_ value: Int) -> SupportedType
}
intOrDouble
handles the problem of numbers being ambiguous when represented as text. For example: 1
is Int
or Double
. If intOrDouble(1)
is used to create SupportedType
, then both .int
and .double
returns 1
. On the contrary if you use .int(1)
, only .int
returns 1
and .double
returns nil
.
ObjectMapper
final class ObjectMapper {
func serialize<T: Serializable>(_ value: T?) -> SupportedType
func deserialize<T: Deserializable>(_ type: SupportedType) -> T?
// + other overloads for supported types mentioned below
}
ObjectMapper
maps objects to SupportedType
. It has two types of methods:
serialize
- Takes Swift objects and transforms them toSupportedType
.deserialize
- TakesSupportedType
and transforms them to Swift objects.
Supported Swift types:
T?
[T]?
[String: T]?
[T?]?
[String: T?]?
where T
conforms to the Map protocol (depends on the method). If T
does not conform to this protocol, you need to pass the instance of Transformation
as the second parameter named using
.
As you can see deserialize
always returns an optional type. nil
is returned if SupportedType
is .null
or cannot be converted to T
.
serialize
accepts both optional and non optional types. If nil
is passed then the result SupportedType
is .null
.
[T]?
differs from [T?]?
in deserialization. If one of the elements from array is nil
(SupportedType
is .null
or the object cannot be deserialized) then everything is discarded and nil
is returned. In case of [T?]?
nil
value will be added to the array. The same applies to the dictionary.
Serializer
protocol Serializer {
func serialize(_ supportedType: SupportedType) -> Data
func deserialize(_ data: Data) -> SupportedType
}
Serializer
represents some object which maps SupportedType
to NSData
. You don't have to implement Serializer
in order to map SupportedType
to NSData
, but it is recommended because then the object can be used in other libraries. (This protocol only provides standardized API.)
Sometimes (almost always) it is easier to work with String
instead of Data
. So we added extension methods to Serializer
:
extension Serializer {
func serialize(toString supportedType: SupportedType) -> String
func deserialize(fromString string: String) -> SupportedType
}
Note: String
is converted to Data
(and back) using UTF-8 coding.
TypedSerializer
protocol TypedSerializer: Serializer {
associatedtype DataType
func typedSerialize(_ supportedType: SupportedType) -> DataType
func typedDeserialize(_ data: DataType) -> SupportedType
}
Extends Serializer
with generic type and methods. Sometimes you may get data from another library not as NSData
but, for example, as JSON (Any
with specific structure) and transforming them back and forth is not good for performance.
#### Pre-implemented Serializers
JsonSerializer
As its name suggests it works with JSON. It conforms to TypedSerializer
protocol and the DataType
is Any
. Requirements for the data format (Any
or NSData
) are the same as in NSJSONSerialization
.
Map protocol
Deserializable
protocol Deserializable {
init(_ data: DeserializableData) throws
}
Allows object conforming to this protocol to be deserialized from SupportedType
using ObjectMapper
. In this init
you need to initialize the object using DeserializableData
(see DeserializableData). If for some reason the object cannot be created (wrong data), then throw DeserializationError
.
Serializable
protocol Serializable {
func serialize(to data: inout SerializableData)
}
Allows object conforming to this protocol to be serialized to SupportedType
using ObjectMapper
. In serialize
set data you want to serialize (it does not have to be everything) to SerializableData
(see SerializableData).
Warning: This method can easily break thread safety if serialized data are mutable (immutability and structs are your friends here).
Mappable
protocol Mappable: Serializable, Deserializable {
mutating func mapping(_ data: inout MappableData) throws
}
Mappable
protocol combines both Deserializable
and Serializable
. It provides default implementation for serialize
but init
needs to be implemented by hand, usually like this:
struct SomeObject: Mappable {
init(_ data: DeserializableData) throws {
try mapping(data)
}
...
This also means that the object has to be initialized before calling the mapping
method.
If you change the default implementation of init
or serialize
, do not forget to call try mapping(data)
(in init
) or mapping(&data)
(in serialize
).
In mapping
you have access to MappableData
(see MappableData) which allows you to specify how to map object at one place. For this the fields must be mutable. Immutable fields needs to be defined separately in init
and serialize
like so:
struct SomeObject: Mappable {
let constant: Int?
var variable: Int?
init(_ data: DeserializableData) throws {
constant = data["constant"].get()
try mapping(data)
}
func serialize(to data: inout SerializableData) {
data["constant"].set(constant)
mapping(&data)
}
mutating func mapping(_ data: inout MappableData) throws {
data["variable"].map(&variable)
}
}
Throws works the same as in Deserializable
.
Warning: Same problems with thread safety as in Serializable
.
DeserializableData/SerializableData/MappableData
They are used in corresponding methods in the Map protocols. They provide many overloads of one specific method and a subscript. The subscript is used as a key in a dictionary and it can be nested like so:
data["a"]["b"]
data[["a", "b"]]
data["a", "b"]
These all mean that data corresponds to a dictionary with the key "a" which is another dictionary with the key "b".
The specific method has overloads for the same types as ObjectMapper
with the same behavior (see ObjectMapper) and for each of them there are three choices:
- same as in
ObjectMapper
- works with optional type, nil represents .null try
- works with non optional type and throws exception if .null is foundor
- works with non optional type and replaces .null with value from or.
DeserializableData
DeserializableData
is used in init
in Deserializable
. The method is named get
and it retrieves values from data. Usage:
let value: Int? = data["value"].get()
let value: Int = try data["value"].get()
let value: Int = data["value"].get(or: 0)
let value: X? = data["value"].get(using: XTransformation())
let value: X = try data["value"].get(using: XTransformation())
let value: X = data["value"].get(using: XTransformation(), or: X())
SerializableData
SerializableData
is used in serialize
in Serializable
. The method is named set
and it sets values to data. Usage:
data["value"].set(value)
data["value"].set(value, using: XTransformation())
Note: set
does not have overloads for try
and or
(there is no need to because it accepts both optionals and non optionals).
MappableData
MappableData
is used in mapping
in Mappable
. The method is named map
and it either behaves like get
or set
depending on the context. Usage:
data["value"].map(&value) // var value: Int?
try data["value"].map(&value) // var value: Int
data["value"].map(&value, or: 0) // var value: Int
data["value"].map(&value, using: XTransformation()) // var value: X?
try data["value"].map(&value, using: XTransformation()) // var value: X
data["value"].map(&value, using: XTransformation(), or: X()) // var value: X
Note: try
and or
affects result of map
only in deserialization.
Transformations
Transformations provide another way of specifying how an object should be mapped. They are used to either override behavior of methods in the Map protocol or to allow mapping of type which does not conform to the Map protocol.
There are three types of them: DeserializableTransformation
(only for deserialization), SerializableTransformation
(only for serialization) and Transformation
(both). Also all of the specialized implementations (AnyTransformation
, SupportedTypeConvertible
, ...) have three versions with corresponding name.
Best way to learn how to create a new one is to look at existing code.
Pre-implemented Transformations
EnumTransformation
- usesRawRepresentable
URLTransformation
-String
toNSURL
(using of relative or absolute path can be specified ininit
)
Date types
CustomDateFormatTransformation
-init
with formatString used asNSDateFormatter.dateFormat
DateFormatterTransformation
-init
withNSDateFormatter
DateTransformation
-Double
as timeIntervalSince1970ISO8601DateTransformation
-String
in ISO8601 format
Value types
BoolTransformation
DoubleTransformation
IntTranformation
StringTransformation
AnyTransformation
AnyTransformation
represents Swift pattern for using protocols with associated types as variable types. To convert any instance of Transformation
to it, simply call transformation.typeErased()
. This is often needed in specialized implementations of Transformation
mentioned below.
Note: AnyTransformation
has variants for only deserialization or serialization, which also have method typeErased()
. So sometimes it may be necessary to specify output type of this method explicitly. For example:
let transformation = IntTransformation()
let anyTransformation: AnyTransformation = transformation.typeErased()
let anyDeserializableTransformation: AnyDeserializableTransformation = transformation.typeErased()
let anySerializableTransformation: AnySerializableTransformation = transformation.typeErased()
SupportedTypeConvertible
Extending type with SupportedTypeConvertible
provides default implementation of the Map protocol if there already is Transformation
for that type. All value types with transformations and NSURL
conforms to this protocol.
Here is an example implementation for Int
:
extension Int: SupportedTypeConvertible {
static var defaultTransformation = IntTransformation().typeErased()
}
Note: This allows types like Int
to be used directly in ObjectMapper
without need to pass the transformation.
CompositeTransformation
CompositeTransformation
allows you to reuse already existing Transformation
to transform the value of the type TransitiveObject
to/from SupportedType
. Then you only need to write code for converting that TransitiveObject
to/from Object
.
DelegatedTransformation
DelegatedTransformation
is similar to CompositeTransformation
in that it uses another Transformation
, but there is no other conversion after that. Typically this is used to specialize more generic Transformation
. For example this is implementation of ISO8601DateTransformation
:
struct ISO8601DateTransformation: DelegatedTransformation {
typealias Object = Date
let transformationDelegate = CustomDateFormatTransformation(formatString: "yyyy-MM-dd'T'HH:mm:ssZZZZZ").typeErased()
}
Because there is already CustomDateFormatTransformation
which handles transformation to/from .string
, it is not necessary to implement it again here. It is sufficient to specify the format used.
Polymorph
protocol Polymorph {
/// Returns type to which the supportedType should be deserialized.
func polymorphType<T>(for type: T.Type, in supportedType: SupportedType) -> T.Type
/// Write info about the type to supportedType if necessary.
func writeTypeInfo<T>(to supportedType: inout SupportedType, of type: T.Type)
}
Polymorph
represents an object that can decide (at runtime) to which object should be the data deserialized and what metadata should be kept about the object concrete type when being serialized to SupportedType
.
To use Polymorph
initialize ObjectMapper
with it. For example:
let objectMapper = ObjectMapper(polymorph: StaticPolymorph())
There is, at the moment, only one implementation (StaticPolymorph
) but once Swift adds reflexion, we will implement new one (dynamic). Also feel free to implement your own polymorphism if ours is not universal enough for you. In extreme cases you may even want to "hardcode" which types should be used.
Example of what can polymorph do:
class A: Mappable {
let value: Int?
...
}
class B: A {
let text: String?
...
}
struct MyPolymorph: Polymorph {
// If B is castable to T and supportedType contains a dictionary with key "type" and the value "B", then the type to use is `B`, otherwise does nothing.
func polymorphType<T>(for type: T.Type, in supportedType: SupportedType) -> T.Type {
if let bType = B.self as? T.Type, supportedType.dictionary?["type"]?.string == "B" {
return bType
}
return type
}
// If T is B, write info about it into supportedType.
func writeTypeInfo<T>(to supportedType: inout SupportedType, of type: T.Type) {
if type == B.self {
supportedType.addToDictionary(key: "type", value: .string("B"))
}
}
}
let objectMapper = ObjectMapper()
let objectMapperWithPolymorh = ObjectMapper(polymorph: MyPolymorph())
let aType: SupportedType = .dictionary(["value": .int(1)])
let bType: SupportedType = .dictionary(["value": .int(2), "text": .string("text"), "type": .string("B")])
// Deserialization
let aObject: A? = objectMapper.deserialize(aType) // A(value: 1) - no surprise here
let bObject: A? = objectMapper.deserialize(bType) // A(value: 2) - the rest of the dictionary is ignored
let aPolymorphic: A? = objectMapperWithPolymorh.deserialize(aType) // A(value: 1) - again the same result
let bPolymorphic: A? = objectMapperWithPolymorh.deserialize(bType) // B(value: 2, text: "text") - this time the polymorph comes into play
// Serialization
objectMapper.serialize(aObject) // .dictionary(["value": .int(1)])
objectMapper.serialize(bObject) // .dictionary(["value": .int(2)])
objectMapperWithPolymorh.serialize(aPolymorphic) // .dictionary(["value": .int(1)]) - so far no difference
objectMapper.serialize(bPolymorphic) // .dictionary(["value": .int(2), "text": .string("text")])
objectMapperWithPolymorh.serialize(bPolymorphic) // .dictionary(["value": .int(2), "text": .string("text"), "type": .string("B")]) - type is added
StaticPolymorph
StaticPolymorph
resolves types by looking into SupportedType
for dictionary entries with the specific key (which key is used is determined by object type at input). Then the value for that key is compared to names of known types. If match is found than the correct type is return otherwise it returns the input type. When serializing the StaticPolymorh
adds into SupportedType
the key value pair that corresponds to the serialized type.
StaticPolymorph
affects only objects which implement the Polymorphic
protocol. For other types polymorphType
returns the input type and writeTypeInfo
does nothing.
Note: Implementing Polymoprhic
is not enough for an object to be used in ObjectMapper
. To solve this, there are type aliases that combines Polymorhic
with the Map protocol: PolymorphicDeserializable
, PolymorphicSerializable
and PolymorphicMappable
.
Note: Limitation of StaticPolymorph
is that only classes can be used. It is not possible to use a protocol and structs.
Polymorphic
Polymorphic
is defined like this:
protocol Polymorphic: AnyObject {
static var polymorphicKey: String { get }
static var polymorphicInfo: PolymorphicInfo { get }
}
polymorphicKey
represents the key mentioned above. (Where to look for a name of the type.) polymorphicKey
can be overriden. That allows each type to be identified with the key and name combination. It is not defined what happens if more than one key is present in SupportedType
with valid names! There can be multiple subtypes with the same key or name as long as the combination is unique.
polymorphicInfo
defines the type name and its subtypes (they don't have to be direct subtypes). It cannot be checked if these types are really subtypes but this won't be a problem if you use GenericPolymorphicInfo
which does that check. If registered subtype is not a real subtype then StaticPolymorph
will ignore it (but don't rely on this behavior). When StaticPolymorph
resolves subtypes it relies only on information provided by polymorphicInfo
, so subtypes which are not registered in input type (or in registered subtype) don't exist for it. To prevent potential misuse it is prohibited to use Polymorphic
as input type if it does not override polymorphicInfo
(as is seen in the example below).
Polymorphic
provides method createPolymorphicInfo()
which returns GenericPolymorphicInfo
. This method has optional parameter name
which represents the polymorphic name of the type (default value the is real name of the type). GenericPolymorphicInfo
allows you to register subtypes with overloads of register()
and with()
(with()
returns self
to allow chaining).
Example
class A: Polymorphic {
class var polymorphicKey: String {
return "K"
}
class var polymorphicInfo: PolymorphicInfo {
return createPolymorphicInfo(name: "Base").with(subtypes: B.self, D.self)
}
}
class B: A {
override class var polymorphicInfo: PolymorphicInfo {
return createPolymorphicInfo().with(subtype: C.self)
}
}
class C: B {
override class var polymorphicKey: String {
return "C"
}
}
class D: C {
override class var polymorphicInfo: PolymorphicInfo {
return createPolymorphicInfo()
}
}
Note: This example omits implementation of the Map protocol.
There are few things to notice.
C
overridespolymorphicKey
that means:A
andB
have the key "K" andC
andD
have the key "C". SoSupportedType.dictionary(["C": .string("C")]
representsC
butSupportedType.dictionary(["K": .string("C")]
means nothing in this context.A
has explicit name "Base". SoSupportedType.dictionary(["K": .string("Base")]
representsA
.C
does not overridepolymorphicInfo
. This means thatC
cannot be used as the input type (exception will be raised) butD
can be, even though it won't ever resolve to another type.D
is registered inA
notB
. Because of that,B
does not know aboutD
. So ifB
is the input type, you can never getD
as subtype.A
knows aboutC
because it is register inB
which is registered inA
.
Thread safety
DataMapper is designed to be used on the background thread (default implementation is thread safe). If you want to use it that way you need to make sure that implementations of all methods from the Map protocols are thread safe as well (or that the objects you are using cannot be used at two threads simultaneously). Your custom implementations of protocols like Serializer
, Polymorph
, Transformation
etc. must be thread safe too.
Versioning
This library uses semantic versioning. Until the version 1.0 API breaking changes may occur even in minor versions. We consider the version 0.1 to be prerelease, which means that API should be stable but is not tested yet in a real project. After that testing, we make needed adjustments and bump the version to 1.0 (first release).
Author
- Tadeas Kriz, [email protected]
- Filip Dolník, [email protected]
Used libraries in tests
License
DataMapper is available under the MIT License.