KeyPath + String

TLDR

Swift should add first-class support for string-ifying KeyPath. I realize that you moved heaven and earth to keep KeyPath and String from EVER knowing about each other, but that has forced everyone to jump through lots of hoops.

Consider this case:

Context

I'm trying to turn this Predicate:

let p = #Predicate<Foo> { $0.title == "blah" }

Into this SQL statement's WHERE clause (a thing that Apple explicitly designed for and which they do for SwiftData!):

SELECT * FROM Foo WHERE title = "blah"

Using the Predicate's expressions property, which hands me a ReferenceWritableKeyPath<Foo, String>:

extension PredicateExpressions.KeyPath: MyPredicateExpression
{
    public func myQuery() -> String
    {
        let kpType = type(of: keyPath)
        let rootType: Any.Type = kpType.rootType
        
        print("string describing keyPath: \(String(describing: keyPath))")
        print("kpType: \(kpType)")
        
        // This is what I WANT:
        return keyPath.propertyName    // A String!
    }
}

The above prints the following, so the name of the property ("title") IS in here somewhere! Printing the description and splitting on the period is fragile though; can't rely on that never changing in the future.

string describing keyPath: \Foo.title
kpType: ReferenceWritableKeyPath<Foo, String>

The Hoops

So you know what people are doing instead? They're writing entire macros to generate THIS so that they can match the KeyPath to the string value they need:

This is crazy. Why force everyone to do this? Why can't KeyPath (at least the specialized types) expose a propertyName string value?

2 Likes

I would love for this to exist, but the code size impact would be high. KeyPaths will probably support functions soon, which means that you'd need a string for every method and var in your program for this to be truly ubiquitous.

It would be cool if Swift had the ability to guarantee that a name existed for any var or method in a type, and to represent types like @Named KeyPath<Foo, Bar> or @Named () -> (). Even that would be somewhat fraught though, if you're using an extension to add fields to a protocol that a particular type happens to conform to.

The above prints the following, so the name of the property ("title") IS in here somewhere!

For context, the CustomDebugStringConvertible impl for KeyPath scrounges around for the name from various sources, ranging from the objc selector, type metadata for stored properties, and calling dlysm on the function pointer. You can do this yourself from outside the stdlib, but these aren't guaranteed to be there; you can turn off type metadata with a compiler flag, and for reasons that I don't fully understand dlsym sometimes doesn't work in debug builds (and idk if it works at all in optimized ones).

2 Likes

Multiple Paths

I realize KeyPaths can have multiple path components and that this complicates the picture.

let kp = \Foo.bar.baz.blah

That’s not a problem.

kp.propertyName    // returns “blah”

kp[1].propertyName   // returns “baz”

Or:

kp.propertyNames    // returns [“bar”, “baz”, “blah”]

Anything similar would make using KeyPath at the boundary of apps, where Swift meets something not-Swift (in my example, an SQL statement), much nicer.

So make the returned value an optional String?

Objective-C was throwing around string-ed KeyPaths back when computers had 256MB hard drives and the Swift Core Team was in diapers.

I find it hard to believe we no longer have the technology to make KeyPaths and Strings play together somehow.

Man, your tone and attitude are exhausting. Every single thread is like this. Why would anyone want to engage in helping you achieve your goals, or even find answers to your questions, if this is how you talk to, and about, them?

Is the technology there? Of course. Can this be exposed? Of course. Why hasn't it? Because there hasn't been a pressing need to date, and nobody has sufficiently worked out the hard problems and edge cases, and coming up with a type-safe API for it. If you want to think through the hard stuff and pitch it, then great! But it's honestly extremely difficult to try to be sympathetic to the problems you're (rightfully) coming up against because of how you treat the project and its contributors. We're here to collaborate constructively on problems and solutions — being needlessly antagonistic isn't a path to getting there.

30 Likes

Thanks. Antagonism wasn’t my intent and I apologize if that’s how it came off. It’s not reasonable to be angry with Swift or the Swift team over a missing feature like this. That’s why I came here: to point out an example of why there’s a need for it and where it would be really useful.

I don’t think it’s wrong to point out that Swift’s predecessor had some advantages in this area. The Swift implementation picks up different advantages, of course, but it’s fair to suggest that maybe some of the tradeoffs made in Swift’s KeyPath implementation could be addressed so that we end up with all the type-safety of the new KeyPath and at least some of the flexibility of the old.

I wasn’t attacking anyone, nor angry at anyone, nor asking for help. Sometimes it feels like you have to type-on-eggshells here because everyone defaults to assigning the worst possible intention and the least generous interpretation when there is doubt. It can’t possibly just be an attempt at humor or hyperbole. It must be evil.

This isn’t unique to this forum, of course, but I find that tech forums are especially sensitive places.

(I think maybe you were part of my 50% question, 50% rant on Swift 6 concurrency. There there was frustration. The constant surprises and 300 new build errors from one beta of Xcode to the next were rightfully frustrating. But that was a different thread. This one is just a suggestion for why this feature would be great.)

3 Likes

I think that's very valuable, and always appreciated! I think crafting that message more softly would go a long way toward how it's received.

If instead of

your approach was more inquisitive, and aimed at finding a solution, the thread would go in a significantly more positive direction. If you were to:

  1. Drop "This is crazy"
  2. Phrase "Why force everyone to do this?" as something like "Is there a reason KeyPaths don't natively make this easier?"
  3. Phrase "Why can't KeyPath" as "Are there any blockers to allowing KeyPath"

then your post would make for the start of some excellent constructive discussion, instead of sounding (at least to me) like a complaint lodged at the authors of this feature (without an acknowledgement that this could just be a feature that could be implemented as opposed to an intentional decision on their part).

I think you ask a lot of good questions, and KeyPaths in particular are an area of the language that have a lot of room for improvement — hence the multiple threads. I know you intentionally take the time to write on these forums; you don't have to do that, and engaging here is appreciated. Reflecting on your particular writing style can help your time and effort make an even bigger impact — for others, and yourself.

13 Likes

In any case, writing style aside, it would be interesting to look at the way KeyPath's current implementation of debugDescription does what it does, and try to list deficiencies (if any), or see if there'd be a nice way to expose the internals as public API.

A lot of KeyPath is built around internal knowledge of the types of properties being represented under the hood. Could that somehow be exposed as API? That could be interesting, too.

5 Likes

Macro-Immune Cases

Another reason to implement string-ification is that not all cases can be covered by a macro that pairs the AnyKeyPath with a property name. Consider this Predicate, where filePaths is just an array of strings on MasterCue:

#Predicate<MasterCue>{ $0.filePaths.count == 5 }

// Expanded:
Foundation.Predicate<MasterCue>({
    PredicateExpressions.build_Equal(
        lhs: PredicateExpressions.build_KeyPath(
            root: PredicateExpressions.build_KeyPath(
                root: PredicateExpressions.build_Arg($0),
                keyPath: \.filePaths
            ),
            keyPath: \.count
        ),
        rhs: PredicateExpressions.build_Arg(5)
    )
})

Here, count is not a property of MasterCue, so a macro can't catch it. So the way I'm dealing with this is by attempting to cast the KeyPath as my PredicateExpressions.KeyPath and, if that fails, taking the description of the KeyPath, splitting on the . and seeing if the final component is "count".

If so, I can transform it into the SQL terminology I need:

ARRAY_LENGTH(`filePaths`) = 5

But being able to ask the KeyPath for the propertyName would be a whole lot more robust and stable.

1 Like

BTW, this isn't a full answer to your question on a general purpose API but just adding something specific to your Predicate use case, PredicateExpressions.KeyPath does have a (small) API that allows for detecting common key paths that you might find in a predicate that are not typically properties that you could exhaustively check for on your model: the kind property. This property can tell you if you're dealing with a keypath like Collection.count so that you can correctly map it to something like ARRAY_LENGTH, but it will be nil for custom properties of your model that aren't these few known cases so today, like SwiftData, you'd need to exhaustively list the other ones.

I can't speak much to the general case as I don't know the ins-and-outs of cases where debugDescription fails today (I know there are some that I've seen in the past) but wanted to mention that Predicate API in case that helps out a bit in your predicate work.

1 Like

Oh that's extremely useful. Thanks! I was wondering how the SwiftData guys were handling this.

Key paths are paths, so expecting to pull out a single property name as a string is sort of a type error at the conceptual level. But they do have accurate reflective information about the components of that path — they have to, because it determines the identity of the path — and there’s no reason we couldn’t have an interface presenting that if someone wants to figure out what it ought to look like. Presumably, each component would be some sort of enum you could switch over, and property components would have data like the property name that you could recover.

Even function components (if we add them) don’t pose a deep problem for this; you just might see an opaque component if you construct a path that way and then try to break it down.

7 Likes

That’s good to hear. When people implied that there are edge cases where KeyPath exists without some string token, I tried to imagine how you’d create one:

let kp = \SomeType.{{what-goes-here-if-not-a-string?}}

(I’m actually curious how Foundation manages to determine if a KeyPath is .count or .isEmpty for the API that @jmschonfeld listed without being able to specialize the generics involved.)

I can see the complexity and danger involved in creating a KeyPath from Strings. But I don’t see the downside of having the ability to ask, “Here is a KeyPath. What are the literal string tokens that were used to create its components?” Having that information would greatly improve ergonomics at the boundary between Swift and Not-Swift.

Here's the relevant code: swift-foundation/Sources/FoundationEssentials/Predicate/PredicateExpression.swift at 2f4f5b80cfb4cd4540e1e75bc07dff4a54a5ea47 · swiftlang/swift-foundation · GitHub

If I understand this code correctly, it performs the following checks:

  • Check if the key path's Root type is a Collection.
  • If yes, check if the key path is literally \String.count, Substring.count, \Array.count, or \Set.count (or the equivalent for isEmpty, first, last). This comparison is performed by comparing against the actual key path literals \String.count etc., so it only works for these few hardcoded collections.

And we can verify that this is actually the case. Take this sample code:

import Foundation

struct S {
    var array: [Int] = [1, 2, 3]
    var slice: ArraySlice<Int> = [1, 2, 3].dropFirst()
}

let p1 = #Predicate<S> { $0.array.isEmpty }
let p2 = #Predicate<S> { $0.slice.isEmpty }

let exp1 = p1.expression as! PredicateExpressions.KeyPath<
    PredicateExpressions.KeyPath<
        PredicateExpressions.Variable<S>,
        Array<Int>
    >,
    Bool
>
print("exp1.kind:", exp1.kind)

let exp2 = p2.expression as! PredicateExpressions.KeyPath<
    PredicateExpressions.KeyPath<
        PredicateExpressions.Variable<S>,
        ArraySlice<Int>
    >,
    Bool
>
print("exp2.kind:", exp2.kind)

This prints the PredicateExpressions.KeyPath.kind for two different predicates p1 and p2. p1 evaluates Array.isEmpty, whereas p2 evaluates ArraySlice.isEmpty.

Result:

exp1: ….CommonKeyPathKind.collectionIsEmpty
exp2: nil

In other words: it only detects the collectionIsEmpty kind if the collection in question is one of the common collections (Array in this case). Since ArraySlice is not in the list of hardcoded collections, it returns nil for that predicate.


In general, the Predicate code in Foundation performs some very low-level and unsafe operations on key paths to determine if a given key path can be used in a Predicate. Take a look at the code in KeyPath+Inspection.swift. It starts by casting an AnyKeyPath to a raw pointer and then looks into particular bits in the raw memory to figure out what kind of key path it is.

7 Likes
enum Foo {
  case foo
  case bar
}

struct Test {
  let value: [Foo:String]
}

let kp = \Test.value[.foo]

And you can do even more convoluted

func someEnum() -> Foo { 
   return .bar 
} 

let kp = \Test.value[someEnum()] 
5 Likes

Ah, that makes it unusable for me. I need to be able to handle .count on Dictionary properties, which are super common in "document store" databases like Couchbase. I can understand why ArraySlice and other uncommon Collection types aren't handled, but Dictionary is one of the "big three" collection types. Is it just the difficultly in specializing the Key and Value that caused the exclusion?

Ha, this makes me feel less bad about my hacky "split on the description" workaround. I always forget that Foundation is open source now.

Kind() + Hack

There's almost zero examples of parsing Predicate with expression, so in case it helps someone else in the future, I ended up combining the two approaches:

let lastKeyPathComponent = String(String(describing: keyPath).split(separator: ".").last ?? "")

if self.kind == .collectionCount || lastKeyPathComponent == "count"
{
    guard let nestedValue else {
        throw .invalidPredicate("Expected a nested KeyPath defining the collection to '.count', but had none.")
    }
            
    if rootType is CouchArrayExpressible.Type || rootType is CouchSetExpressible.Type
    {
        return ("ARRAY_LENGTH(\(nestedValue))", [])
    }
    else if rootType is CouchDictionaryExpressible.Type
    {
        return ("OBJECT_LENGTH(\(nestedValue))", [])
    }
    else if rootType is String.Type
    {
        
    }
}
else if self.kind == .collectionIsEmpty || lastKeyPathComponent == "isEmpty"
{ ... }

(The CouchArrayExpressible and similar are just empty Protocols that let me figure out what kind of collection the root type is without having to specialize. Needed because different types require either ARRAY_LENGTH or OBJECT_LENGTH in SQL.)

I experimented once with using withUnsafeBytes to extract the underlying KeyPath data structure, and reassemble all of the components based on the Swift ABI into a nice type. All the information is there, there's just no user-friendly way of accessing it.

This was years ago. I might try and dig it out if there's interest.

2 Likes

I also vaguely remember reading somewhere that adding this feature would be considered unsafe, as users would wind up encoding/decoding key paths, which in (for example) a networking context, could be altered in transit to expose unauthorised information.

Could such a feature be marked as unsafe, so those that know the risks of stringed/codable KeyPaths could use responsibly.

adding this feature would be considered unsafe, as users would wind up encoding/decoding key paths, which in (for example) a networking context, could be altered in transit to expose unauthorised information

If KeyPaths could be constructed from Strings, that might be true. But I’m proposing a one-way trip: KeyPath to String. This would expose all the same information as calling description() does today, but in a structured, reliable way. Adding this one-way trip would not expose any additional info beyond what can be gotten today, informally. (Although @John_McCall mentioned possibly doing just that, via an enum that holds relevant information about each component of the KeyPath—the name, type, etc.)

1 Like

Just as with decoding an arbitrary function value, any ability to decode an arbitrary key path is insecure and should not be done across a security boundary.[1] But yeah, there's no security problem with allowing a key path to be inspected or destructured. Writing code that destructures a key path may be unwise — such code is likely to be less parametric than code that treats the key path opaquely — but it's not a security problem.


  1. It is theoretically possible to put the genie back in the bottle by validating the encoded function or key path on the receiver side. In practice, this is an extremely bad idea. You almost certainly do not need to casually send code around; if you do, you are making a security problem for yourself that you will need to solve very, very carefully. ↩︎

5 Likes