Model relations based on a key in the attributes of a Swift Decodable object

780 views Asked by At

Given the JSON:

[{
        "name": "TV",
        "room": "Living Room"
    },
    {
        "name": "LightBulb 1",
        "room": "Living Room"
    }
]


struct Room: Decodable {
  let name: String
  let devices: [Device]
}
struct Device: Decodable {
  let name: String
}

How can I use the Swift 4 Decodable way of decoding JSON get my model structure correctly serialized? I want to make rooms for every unique string in the room attribute of a device, and add those devices to the device list of that given room.

One way is simply to map this without the room relation and then parse that relation after i have gotten the whole list of Devices, with just running through and creating rooms on demand as i iterate it. But that does not feel like The Swift 4™ way of doing it. Is there a smarter way?

2

There are 2 answers

0
Eivind Rannem Bøhler On BEST ANSWER

I'm making an assumption here - that by "the Swift 4 Decodable way of decoding JSON" you mean calling try JSONDecoder().decode([Room].self, from: jsonData). If that's the case then, to my knowledge, you're out of luck since the JSONDecoder will iterate through its parsed JSON objects and call the initializer Room(from: Decoder) on each. Even if you were to create your own initializer, it would have no way of knowing what the other JSON objects contained.

One way to solve this could be by creating an intermediate Decodable struct reflecting each JSON object's properties, and then create your Rooms by going through an array of these structs.

Here's an example, works fine as an Xcode playground:

import UIKit

struct Room {
    let name:    String
    var devices: [Device]

    fileprivate struct DeviceInRoom: Decodable {
        let name: String
        let room: String
    }

    static func rooms(from data: Data) -> [Room]? {
        return (try? JSONDecoder().decode([DeviceInRoom].self, from: data))?.rooms()
    }
}
struct Device {
    let name: String
}

fileprivate extension Array where Element == Room.DeviceInRoom {
    func rooms() -> [Room] {
        var rooms = [Room]()
        self.forEach { deviceInRoom in
            if let index = rooms.index(where: { $0.name == deviceInRoom.room }) {
                rooms[index].devices.append(Device(name: deviceInRoom.name))
            } else {
                rooms.append(Room(name: deviceInRoom.room, devices: [Device(name: deviceInRoom.name)]))
            }
        }
        return rooms
    }
}

let json = """
[
  {
    "name": "TV",
    "room": "Living Room"
  },
  {
    "name": "LightBulb 1",
    "room": "Living Room"
  }
]
"""

if let data  = json.data(using: .utf8),
   let rooms = Room.rooms(from: data) {

    print(rooms)
}

Or - maybe an even more Swift4'y way of doing this:

import UIKit

struct Room {
    let name:    String
    var devices: [Device]
}

struct Device {
    let name: String
}

struct RoomContainer: Decodable {

    let rooms: [Room]

    private enum CodingKeys: String, CodingKey {
        case name
        case room
    }

    init(from decoder: Decoder) throws {
        var rooms = [Room]()
        var objects = try decoder.unkeyedContainer()
        while objects.isAtEnd == false {
            let container  = try objects.nestedContainer(keyedBy: CodingKeys.self)
            let deviceName = try container.decode(String.self, forKey: .name)
            let roomName   = try container.decode(String.self, forKey: .room)
            if let index = rooms.index(where: { $0.name == roomName }) {
                rooms[index].devices.append(Device(name: deviceName))
            } else {
                rooms.append(Room(name: roomName, devices: [Device(name: deviceName)]))
            }
        }
        self.rooms = rooms
    }
}

let json = """
[
  {
    "name": "TV",
    "room": "Living Room"
  },
  {
    "name": "LightBulb 1",
    "room": "Living Room"
  }
]
"""

if let data  = json.data(using: .utf8),
   let rooms = (try? JSONDecoder().decode(RoomContainer.self, from: data))?.rooms {

    print(rooms)
}

Note - I've used try? a few times in the code above. Obviously you should handle errors properly - the JSONDecoder will give you nice, specific errors depending on what went wrong! :)

0
Cameron Lowell Palmer On

Mapping from one object model to another

To go along with Eivind and since he has already written a good answer, I'll just add my 2 cents... JSON is an object model, so I'd decode the JSON objects and then translate those objects to first-class Swift objects. As long as a server is speaking JSON you must assume it will change at some point and the one thing you don't want is the JSON object model bleeding into or dictating object structure or even variable names in the Swift world. So it is a perfectly sound choice to decode the objects into Plain Ol' Swift Objects (POSOs) and types and then make extensions that will handle conversion from these POSO Decodables to the objects you'll build your app around. The playground I was working in is below, but Eivind beat me to the punch and in his second example goes to the trouble of generating the finalised pure-Swift objects.

Apple's blog on JSON handling has this nice quote

Working with JSON in Swift

Converting between representations of the same data in order to communicate between different systems is a tedious, albeit necessary, task for writing software.

Because the structure of these representations can be quite similar, it may be tempting to create a higher-level abstraction to automatically map between these different representations. For instance, a type might define a mapping between snake_case JSON keys and camelCase property names in order to automatically initialize a model from JSON using the Swift reflection APIs, such as Mirror.

However, we’ve found that these kinds of abstractions tend not to offer significant benefits over conventional usage of Swift language features, and instead make it more difficult to debug problems or handle edge cases. In the example above, the initializer not only extracts and maps values from JSON, but also initializes complex data types and performs domain-specific input validation. A reflection-based approach would have to go to great lengths in order to accomplish all of these tasks. Keep this in mind when evaluating the available strategies for your own app. The cost of small amounts of duplication may be significantly less than picking the incorrect abstraction.

import Foundation
import UIKit

struct RoomJSON
{
    let name: String
    let room: String
}

struct Room
{
    let name: String
    let devices: [Device]
}

struct Device
{
    let name: String
}

extension RoomJSON: Decodable {
    enum RoomJSONKeys: String, CodingKey
    {
        case name = "name"
        case room = "room"
    }
    
    init(from decoder: Decoder) throws
    {
        let container = try decoder.container(keyedBy: RoomJSONKeys.self)
        let name: String = try container.decode(String.self, forKey: .name)
        let room: String = try container.decode(String.self, forKey: .room)
        
        self.init(name: name, room: room)
    }
}

let json = """
[{
    "name": "TV",
    "room": "Living Room"
 },
 {
    "name": "LightBulb 1",
    "room": "Living Room"
 }]
""".data(using: .utf8)!

var rooms: [RoomJSON]?
do {
    rooms = try JSONDecoder().decode([RoomJSON].self, from: json)
} catch {
    print("\(error)")
}

if let rooms = rooms {
    for room in rooms {
        print(room)
    }
}