offworldwelcome
offworldwelcome

Reputation: 1678

How can I encode a Codable type by specifying a subset of its CodingKeys?

I have types for which I provide CodingKeys with custom names for their data members as appropriate. I would like to encode, as required by different call sites, only a subset of a type's CodingKeys before sending the data to a server. The required encodings change from call site to call site, so there's no default implementation to consider.

Example types (for demonstration purposes):

struct Account: Codable
{
    let name: String;
    var balance: Float;

    mutating func set(balance: Float)
    {
        self.balance = balance; // verifications, etc.
    }
}

struct User: Codable
{
    enum CodingKeys: String, CodingKey
    {
        case
            id = "db_id",
            name = "db_name",
            account
            // many more keys
    }

    let id: Int;
    let name: String;
    var account: Account;
    // many more data members
}

Creating an instance:

var user = User(
    id: 1, name: "john", account: Account(name: "checking", balance: 10_000));

Using a JSONEncoder works as expected; it produces the following:

{
  "db_id" : 1,
  "db_name" : "john",
  "account" : {
    "balance" : 10000,
    "name" : "checking"
  }
}

I want to encode a subset of the User type in order to send that data back to a server so that I can update specific data fields instead of updating the entire set of properties of my type. Mock usage example:

user.account.set(balance: 15_000);
let jsonEncoding = JSONEncodeSubset.of(
    user, // instance to encode
    keys: [User.CodingKeys.id, User.CodingKeys.account] // data to include
);

The resulting produced JSON would look like so:

{
  "db_id" : 1,
  "account" : {
    "balance" : 15000,
    "name" : "checking"
  }
}

Server side, we now have the exact data we need to perform our desired update.

Another example: somebody entered the wrong name for our user, therefore another update request looks like this:

user.name = "jon"; // assume the model was modified to make this mutable
let jsonEncoding = JSONEncodeSubset.of(
    user, // instance to encode
    keys: [User.CodingKeys.id, User.CodingKeys.name] // only encode id & name
);

The expected resulting JSON encoding:

{
  "db_id" : 1,
  "name" : "jon"
}

Note that we exclude the information that isn't part of the update (user's account). The objective is to optimize the encoding to include only data that is relevant to that specific request.

Considering I have a large list of objects to update, with different call sites updating different things, I'd like to have a succinct way to perform the encoding task.

  1. Encoding a subset of a type's data members improves encoding performance/memory footprint.
  2. Sending a larger count of smaller encoded objects to the server becomes more efficient.

Does Swift provide any such support for encoding subsets of a type's CodingKeys?

Upvotes: 7

Views: 806

Answers (2)

pawello2222
pawello2222

Reputation: 54426

Expanding on Joakim Danielson's answer you can use CodingUserInfoKey to pass data to userInfo property of JSONEncoder.

extension CodingUserInfoKey {
    static let keysToEncode = CodingUserInfoKey(rawValue: "keysToEncode")!
}

extension JSONEncoder {
    func withEncodeSubset<CodingKeys>(keysToEncode: [CodingKeys]) -> JSONEncoder {
        userInfo[.keysToEncode] = keysToEncode
        return self
    }
}

You need to make CodingKeys conform to CaseIterable and implement a custom encode(to:) method:

struct User: Codable {
    enum CodingKeys: String, CodingKey, CaseIterable { // add `CaseIterable`
        ...
    }
    ...
    
    func encode(to encoder: Encoder) throws {
        let keysToEncode = encoder.userInfo[.keysToEncode] as? [CodingKeys] ?? CodingKeys.allCases
        var container = encoder.container(keyedBy: CodingKeys.self)
        for key in keysToEncode {
            switch key {
            case .id:
                try container.encode(id, forKey: .id)
            case .account:
                try container.encode(account, forKey: .account)
            case .name:
                try container.encode(name, forKey: .name)
            }
        }
    }
}

And use it like this:

let encoder = JSONEncoder().withEncodeSubset(keysToEncode: [User.CodingKeys.id, .account])
let encoded = try encoder.encode(user)

print(String(data: encoded, encoding: .utf8)!)
// prints: {"db_id":1,"account":{"name":"checking","balance":15000}}

Upvotes: 4

Joakim Danielson
Joakim Danielson

Reputation: 51871

This solution requires that you write a custom encode(to:) for all properties but once that is done it should be easy to use.

The idea is to let the encode(to:) encode only those properties/keys that exists in a given array. So given the example above the function would look like this

func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    for key in selectedCodingKeys {
        switch key {
        case .id:
            try container.encode(id, forKey: .id)
        case .account:
            try container.encode(account, forKey: .account)
        case .name:
            try container.encode(name, forKey: .name)
            
        }
    }
}

selectedCodingKeys is new property in the struct

 var selectedCodingKeys = [CodingKeys]()

and we could also add a specific function for encoding

mutating func encode(for codingKeys: [CodingKeys]) throws -> Data {
    self.selectedCodingKeys = codingKeys
    return try JSONEncoder().encode(self)
}

and then the decoding could be done like in this example

var user = User(id: 1, name: "John", account: Account(name: "main", balance: 100.0))
do {
    let data1 = try user.encode(for: [.id, .account])
    let data2 = try user.encode(for: [.id, .name])
} catch {
    print(error)
}

Upvotes: 3

Related Questions