Transform AnyKeyPath back to ReferenceWritableKeyPath Dynamically

Context

I'm writing an abstraction layer that translates Swift model objects into Couchbase database records and back again. I'm struggling to do this in a generic, extendable way with KeyPath.

Consider this model object:

class ModelObject
{
    var id: UUID = UUID()
    var title: String = ""
    var count: Int = 42
}

The Couchbase Swift SDK fetches the stored record of this object and gives me back a simple [String: Any?] dictionary that I can enumerate. The keys are the names of each property on ModelObject.

Attempt

I'm trying to reconstitute ModelObject from the Couchbase dictionary. But I want to do that in a generic, extensible way so that I can handle any kind of properties on ModelObject. I figured I could write a macro that maps string keys to AnyKeyPath, like this:

class ModelObject 
{
    class var keyPathMap: [String: AnyKeyPath] 
    { 
        ["id": \ModelObject.id,
          "title: \ModelObject.title,
           ...]
    }
}

But the problem is that I can't use these to set values because there's no way to cast them to ReferenceWritableKeyPath dynamically.

Even though the rootType and valueType properties of each AnyKeyPath in keyPathMap are set and valid and ready-to-go, there's no way for me to tell Swift: "Look, you've got all the information you need. Just make the thing a ReferenceWritableKeyPath so I can use it. The only reason it's type-erased is so that I could stick it in a collection. The types are there!"

Is there a way for me to do this? SwiftData's schema maps string names to AnyKeyPath. How do they get a ReferenceWritableKeyPath out of that for any possible value type? The only way I can do it is by manually writing out a giant if waterfall:

if let writableKeyPath  = erasedKeyPath as? ReferenceWritableKeyPath<Self, String> 
{
...
} 
else if let writableKeyPath = erasedKeyPath as? ReferenceWritableKeyPath<Self, Int> 
{
...
}
else if...

I really don't want to fall back to @dynamicMemberLookup. My current fallback plan is to just abandon KeyPaths and have a macro generate a function like this for each Model class that adopts my "Couchable" protocol:

func set(value: Any? for key: String) 
{
    if key == "title"
    {
        if let s = value as? String { 
            self.title = s
        } 
    }
    ... // [repeat for all model properties]
}

Is there any way to adapt KeyPaths in this situation?

Update:

I discovered I can do this:

self[keyPath: erasedKeyPath as! ReferenceWritableKeypath] = someValue

This lets me coerce the AnyKeyPath into a ReferenceWritableKeyPath without having to explicitly specify the rootType and valueType in the cast. That'll let me build an extensible method that can handle any type of value.

I plan to make all of my model objects reference types, and all properties that round-trip to Couchbase mutable, so I don't see a reason the stringToKeyPathMap: [String: AnyKeyPath] map would end up with a KeyPath that is not ultimately able to be coerced into a ReferenceWritableKeyPath.

Is that an incorrect assumption? I'd prefer a safer alternative than as!, but everything else requires me to explicitly specify the types.

Am I just out to lunch on how I'm using KeyPaths here?

I think what you might be looking for here is something like ImplicitOpenExistentials if you are building from 6.0 and up.

In order to write through a key path, you fundamentally have to know what type the property has in order to write it. But you can implement an operation that attempts to write a dynamically-type value when the types line up by using dynamic casting in combination with existential opening:

func _tryWrite<Base, Value>(to: inout Base, through: AnyKeyPath, with: Value) throws {
  guard let kp = through as? ReferenceWritableKeyPath<Base, Value> else {
    throw TypeMismatch()
  }
  to[keyPath: kp] = with
}

func tryWrite<Base>(to: inout Base, through: AnyKeyPath, with: Any) throws {
  return _tryWrite(to: to, through: through, with: with)
}

This should work in Swift 6 language mode. (In Swift 5, you'd have to manually open with using the _openExistential hack.)

4 Likes

@Joe_Groff Thanks! That’s less dangerous than the as! approach.

I DO have all the type information at compile-time. Every AnyKeyPath in the collection has a rootType and valueType set.

In fact, while walking the dictionary from Couchbase, I verify that the type of value assigned to the dictionary key matches the value type of the KeyPath with: type(of: kp).valueType before setting it.

The issue I have is that I can’t manually type out a cast for every posssible valueType that a KeyPath might have, since I don’t even know all of them. They might be custom subclasses.

That's a fair point. It would be more robust to open the valueType existential and use that as the type to cast the key path and value, since doing so would also allow for values to be written that can match the key path by subclassing and/or bridging conversions.

1 Like

To close the loop here: I ended up just abandoning KeyPath altogether and having my macro generate a function that walks the stored properties of the class, looks for the name of each as a key in the Couchbase dictionary, and then updates the value by direct access.

Thoughts

I understand what the core team was thinking with KeyPath. But Strings are the boundary between Swift and everything-else-that-needs-to-interoperate-with-Swift. And because you cannot build a KeyPath from a String, it makes it very hard to use at the boundary.

Type safety is good. But taking it to a religious extreme ("Thou shalt not even remember that Strings exist!") does not make for a great API. I'd like to take the external Couchbase String keys and bring them into the compiler-checked KeyPath type, but Swift makes that impossible. It should reconsider.

If you're already using a macro to collect the properties from the class definition, you can generate the mapping:

func keyPath(for key: String) -> PartialKeyPath<Self>? {
  switch key {
  case "foo": \.foo
  case "bar": \.bar
  /*etc.*/
}

There have been previous discussions about "codable key paths" that would allow for this to be derived from metadata for some properties, but we would recommend against doing this for all properties everywhere in the style of Objective-C KVC, since that's a potential security hole.

2 Likes

@Joe_Groff Right. That's originally what I had a macro doing.

But if you're already writing a macro, why pay the performance cost of KeyPath and/or the existential lookup cost involved in all the casting? Or even deal with casting, period?

Just have the macro write out a function that uses direct access.

I generally try to avoid burying tons of stuff in macros—it obfuscates implementations and makes it much harder to grok what's really happening (hello, Swift Data). But in this case, since one was required either way, I just bit the bullet.

@Joe_Groff An interesting twist came up with your tryWrite() approach. Suppose we have this:

class Foo
{
    var name: String? = ""
}

And we have a type-erased AnyKeyPath that, under the covers, is actually this:

ReferenceWritableKeypath<Foo, String?>

We want to set name = nil via this AnyKeyPath. There are now more hoops to jump through because Value in the tryWrite() functions is now nil.

I think I can safely say that the overall ergonomics of KeyPath in Swift are pretty terrible. They seem to have been designed for a very narrow, very limited scope. It's way easier to just capture a closure.

Why are you not just using Codable?

extension ModelObject: Decodable { }
final class ModelObject {
public extension Decodable {
  /// - Parameter codingDictionary: `CodingKey`s paired with their values.
  init(codingDictionary: [String: Any?]) throws {
    self = try JSONDecoder().decode(
      Self.self,
      from: JSONSerialization.data(withJSONObject: codingDictionary)
    )
  }
}
@Test func test() throws {
  let id = UUID()
  let title = "Technofeudalism"
  let count = 4321
  let modelObject = try ModelObject(
    codingDictionary: [
      "id": id.uuidString,
      "title": title,
      "count": count
    ]
  )
  #expect(modelObject.id == id)
  #expect(modelObject.title == title)
  #expect(modelObject.count == count)
}

@Danny Briefly: I’m building a data framework around Couchbase (now that MongoDB has killed Realm) and KeyPaths are used to specify inverse relationships that should be nullified when an object is deleted.

Codable isn’t relevant. And the existence of Codable should not excuse how painful KeyPaths are.

Could you elaborate where the extra complication comes from? Casting to ReferenceWritableKeyPath<Foo, T> when T is Optional still works, and doesn't involve the value at all. If you cast the value to match the key path's type, that should also still work, since as? casting to String? gives you a String??, which would be .some(nil) rather than nil if the value was originally a String?.

That said, I agree having tryWrite-like APIs in the standard library would be a good idea, so that you don't have to work out how to do it correctly in all cases yourself.