Suppose I have this simple class:
final class Foo
{
var title: String = ""
var count: Int = 42
…
}
I have a type-erased AnyKeyPath
and I would like to cast it to a ReferenceWriteableKeyPath<Foo, String>
, but I would like the value parameter to come from a variable, like this:
let erasedKeyPath: AnyKeyPath ... // Exists
let targetType: Any.Type = String.self
if let typedKeyPath = erasedKeyPath as? ReferenceWritableKeyPath<Foo, targetType> {
...
}
This throws an error: Cannot find type 'targetType' in scope
.
Can this be done, somehow, or must KeyPath
parameters always be explicitly typed out in code? I do not want to fallback on @dynamicMemberLookup
.
I’m building an abstraction layer that reconstitutes Swift model objects from Couchbase database records.
The Couchbase Swift SDK gives me a simple [String: Any?]
where the keys are the property names of my model object.
I need to walk that dictionary and set the corresponding value on my model object. To do this, I have a macro that maps the name of each property to the appropriate KeyPath:
class var stringToKeyPathMap: [String: AnyKeyPath] {
[“title”: \Foo.title, …]
}
The hitch is that because the KeyPaths have different valueType
, they must be erased to AnyKeyPath
for this collection. Critically: EVERY SINGLE KEYPATH has a rootType
and valueType
known at compile-time.
So,
we walk the Couchbase dictionary and find the key-value pair for “title”.
we look up the KeyPath for “title” and get back the appropriate AnyKeyPath
.
we can look up the type that this KeyPath addresses: type(of: kp).valueType
and find that it’s String.self
.
we verify that the Couchbase dictionary value assigned to the key “title” is the type that we expect (a String
).
Now we can do this to set the value:
if let writableKeyPath = erasedKeyPath as? ReferenceWritableKeyPath<Self, String> { self[keyPath: writableKeyPath] = stringFromCouchbaseDict }
Great! But I had to manually specify the types in the cast. This means my framework can’t handle any type that I don’t explicitly type out on my keyboard—-EVEN THOUGH the AnyKeyPath
has all the type info it needs! rootType
and valueType
exist and are valid and ready to go.
The issue is about getting the AnyKeyPath
to a ReferenceWritableKeyPath
for any type. That lets my framework handle types that I don’t even know about, such as custom subclasses.
Instead of having the macro generate a dictionary mapping property names to key paths, have the macro generate the code that initialises the properties directly. e.g.
@CouchbaseModel
class X {
var a: String
var b: Int
}
expands to
class X {
var a: String
var b: Int
init(couchbaseData: [String: Any]) {
self.a = couchbaseData["a"] as! String
self.b = couchbaseData["b"] as! Int
}
}
You can also include helpers like @CouchbaseIgnored
to make @CouchbaseModel
ignore a property, and @CouchbaseKey
to specify the key name corresponding to that property in the dictionary from couchbase.
Here is an example implementation:
// declaration:
@attached(peer)
public macro CouchbaseKey(_ key: String) = #externalMacro(module: "...", type: "CouchbaseKey")
@attached(peer)
public macro CouchbaseIgnored() = #externalMacro(module: "...", type: "CouchbaseIgnored")
@attached(member, names: named(init))
public macro CouchbaseModel() = #externalMacro(module: "...", type: "CouchbaseModel")
// implementation:
struct PropertyMapping {
let name: String
let couchbaseKey: String
let type: TypeSyntax
}
enum CouchbaseModel: MemberMacro {
static func expansion(of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
let properties = declaration.memberBlock.members
.compactMap { $0.decl.as(VariableDeclSyntax.self)?.propertyMapping }
return [DeclSyntax(
try InitializerDeclSyntax("init(couchbaseData: [String: Any])") {
for property in properties {
let keyLiteral = StringLiteralExprSyntax(content: property.couchbaseKey)
"""
self.\(raw: property.name) = couchbaseData[\(keyLiteral)] as! \(property.type)
"""
}
}
)]
}
}
extension VariableDeclSyntax {
var propertyMapping: PropertyMapping? {
guard let binding = bindings.first,
let type = binding.typeAnnotation?.type,
let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else {
return nil
}
if attributes.findAttribute("CouchbaseIgnored") != nil {
return nil
}
guard let keyAttr = attributes.findAttribute("CouchbaseKey") else {
return .init(name: name, couchbaseKey: name, type: type)
}
guard case let .argumentList(args) = keyAttr.arguments,
let key = args.first?.expression.as(StringLiteralExprSyntax.self)?.representedLiteralValue else {
return nil
}
return .init(name: name, couchbaseKey: key, type: type)
}
}
extension AttributeListSyntax {
func findAttribute(_ name: String) -> AttributeSyntax? {
for elem in self {
if case let .attribute(attr) = elem,
attr.attributeName.as(IdentifierTypeSyntax.self)?.name.text == name {
return attr
}
}
return nil
}
}
// just a marker
enum CouchbaseKey: PeerMacro {
static func expansion(of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
[]
}
}
// just a marker
enum CouchbaseIgnored: PeerMacro {
static func expansion(of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
[]
}
}
In this example I generate force casts to the desired type, but it's not hard to handle type mismatches in some way. For example, you can write another helper macro that specifies a default value, or generate an init
that throws
.