How to parse array of polymorphic objects?
Closed this issue · 4 comments
Hi! Thanks for wonderful framework.
I've encountered one issue, I can't figure out.
Let's say I have:
class A {
let type: String
/*lots of properties*/
}
and
final class B: A {
/*some other properties*/
}
In my response I have JSON-array of objects and some of them turn out to be B
's. The type of object determined by type
field. Is it possible to use Elevate in this situation? How should I organise my decoding?
Hi @rehsals...it absolutely is possible to use Elevate in this situation!
Problem
Since you have different types inside an Array
, you can't just use the decodable convenience as you alluded to. Something like this won't work.
let properties = try Parser.parseProperties(data: data) { make in
make.propertyForKeyPath("objects", type: .Array, decodedToType: A.self)
}
The problem here is that Elevate doesn't know about polymorphism from the JSON object. All it knows is that you want to construct instances of A
from each JSON object inside the array.
Solution
Instead, you can use a Decoder
to help you "inspect" the data before choosing which class to initialize.
class PolymorphicModelObjectTestCase: XCTestCase {
func testThatElevateCanUseDecoderToSwitchBetweenPolymorphicModelObjects() {
do {
// Given
let json: AnyObject = [
[
"name": "cnoon"
],
[
"name": "rehsals",
"sport": "football"
]
]
let data = try NSJSONSerialization.dataWithJSONObject(json, options: .PrettyPrinted)
// When
let people: [Person] = try Parser.parseArray(data: data, withDecoder: PersonDecoder())
// Then
XCTAssertEqual(people.count, 2)
if people.count == 2 {
XCTAssertTrue(people[0] is Person)
XCTAssertTrue(people[1] is Teammate)
}
// Show me
people.forEach { print($0) }
} catch {
print("Error occurred: \(error)")
}
}
}
class Person: CustomStringConvertible {
let name: String
var description: String { return "Person: { \"name\": \"\(name)\" }" }
init(name: String) {
self.name = name
}
}
final class Teammate: Person {
let sport: String
override var description: String { return "Teammate: { \"name\": \"\(name)\", \"sport\": \"\(sport)\" }" }
init(name: String, sport: String) {
self.sport = sport
super.init(name: name)
}
}
class PersonDecoder: Decoder {
func decodeObject(object: AnyObject) throws -> Any {
let nameKeyPath = "name"
let sportKeyPath = "sport"
let properties = try Parser.parseProperties(json: object) { make in
make.propertyForKeyPath(nameKeyPath, type: .String)
make.propertyForKeyPath(sportKeyPath, type: .String, optional: true)
}
let name: String = properties <-! nameKeyPath
let sport: String? = properties <-? sportKeyPath
if let sport = sport {
return Teammate(name: name, sport: sport)
} else {
return Person(name: name)
}
}
}
I'm fairly certain this example should match your use case exactly. @AtomicCat and I came up with a few different approaches, but this is probably the most elegant solution to this particular issue.
Cheers. 🍻
This is eating at my brain...
Alternative approach with fewer optionals:
import Elevate
class A: Decodable {
let type: String
let thing: String
required init(json: AnyObject) throws {
let typePath = "type"
let thingPath = "thing"
let properties = try Parser.parseProperties(json: json) { make in
make.propertyForKeyPath(typePath, type: .String)
make.propertyForKeyPath(thingPath, type: .String)
}
self.type = properties <-! typePath
self.thing = properties <-! thingPath
}
}
final class B: A {
let other: String
required init(json: AnyObject) throws {
let otherPath = "other"
let properties = try Parser.parseProperties(json: json) { make in
make.propertyForKeyPath(otherPath, type: .String)
}
self.other = properties <-! otherPath
try super.init(json: json)
}
}
class PolyDecoder: Decoder {
func decodeObject(object: AnyObject) throws -> Any {
let typePath = "type"
let properties = try Parser.parseProperties(json: object) { make in
make.propertyForKeyPath(typePath, type: .String)
}
let type: String = properties <-! typePath
if type == "ClassA" {
return try A(json: object)
} else if type == "ClassB" {
return try B(json: object)
} else {
throw ParserError.Validation(failureReason: "Unknown type")
}
}
}
let json = [
[ "type": "ClassA", "thing": "foo" ],
[ "type": "ClassB", "thing": "bar", "other": "whatever" ],
]
do {
let data = try NSJSONSerialization.dataWithJSONObject(json, options: .PrettyPrinted)
let objects: [AnyObject] = try Parser.parseArray(data: data, withDecoder: PolyDecoder())
// Show me
objects.forEach { print($0) }
} catch {
print("Error occurred: \(error)")
}
If you don't need B to be a subclass of A, one option would be to put the extra properties that B has in an optional struct in A and handle it in a single decoder.
If they don't need to be classes, you might have additional options by using structs.
Yep, totally a valid solution as well @AtomicCat. @rehsals take your pick. Depending on your actual use case, one may make more sense than the other.
Cheers. 🍻
Great! Thanks for your help, @cnoon. And thanks to @AtomicCat for another approach.