Nike-Inc/Elevate

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?

cnoon commented

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.

cnoon commented

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.