Skip to content
Closed
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions apollo-ios/Design/swift-identifiable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Summary

This document proposes a new GraphQL directive named `@identity`, to be used to mark a field which can uniquely identify an object.

# Introduction

SwiftUI makes heavy use of the [Identifiable](https://developer.apple.com/documentation/swift/identifiable) protocol, which is used to track the identity of entities across data changes. If an object with a List is replaced with a different object with the same identity, SwiftUI will animate the item changing instead of animating an insertion. Objects that do not conform to the Identifiable protocol require additional boilerplate to be usable inside SwiftUI.

Apollo Client iOS could assist the developer by adding conformance to the Identifiable protocol to its generated models. Selecting the field to be used as an identity is done by adding a new GraphQL directive.

A concept of identity is required to allow response objects to be cached. The various Apollo Client projects have mechanisms that allow for identifiers to be selected through additional code. The new directive could allow schema authors to assist client authors with caching.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new directive could allow schema authors to assist client authors with caching.

This is a nice ideal but not likely, as you noted below in Usage in operations.

  • Federated schema authors already have the @key directive but I don't think those are passed through in the API schema SDL, nor introspection.
  • You'll end up with a string of different directives if there isn't uniformity across all the clients.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree this document is coming close to a xkcd 927 situation. I wasn't aware of the @key directive, but it not being revealed to the clients is a deal-breaker for my use case.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have not previously defined any directives that are exposed as part of the public schema and are intended for consumption by the Apollo client's specifically. While this is certainly possible, we need to consider all of the issues this can cause. These directives would need to be namespaced accordingly. It is very possible that some schema's out there already have an @identity directive that they are using for some other use case. We can't just assume that directive is what we are looking for and consume it on the client for this usage.

Namespacing of directives doesn't really have great support in the GraphQL spec currently. We'd need to use some ugly directive name like @apollo_client_identity. I know that an @core directive was discussed by us at some point to help with this, but it's not an accepted and used feature currently. @martinbonnin knows more about that than I do.


# Definition

The directive is defined as:
```graphql
directive @identity(scope: IdentityScope = SELECTION) on FIELD | FIELD_DEFINITION

enum IdentityScope {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a novel idea but you can probably derive the scope from the context in which the directive is used; interface field vs. type field vs. selection field.

This may also introduce a bunch of validation required to make sure it isn't used with excessive scope, i.e.: should a selection field be able to define global scope? Probably not.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed my mind regarding the SELECTION scope. Its application is too limited (protocol conformance only), so I took it out. I think removing this case also removes the reason for clients to widen the scope themselves.

SELECTION
TYPE
SERVICE
GLOBAL
}
```

This directive MUST be used on a field with a non-nullable scalar type. Other types are not supported.

## Scope parameter

The directive can have a parameter indicating the scope of the identity. They are ordered from _narrowest_ to _widest_:

1. `SELECTION`: The identifier is only unique within the current list. Identifiers may be reused between different objects with the same typename.
2. `TYPE`: The identifier is unique for the type, e.g. an auto-incrementing column from a database table.
3. `SERVICE`: The identifier is unique across the current GraphQL Service.
4. `GLOBAL`: The identifier is unique across all GraphQL services. This can be used by identifiers generated according to [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122) or [RFC 9562](https://datatracker.ietf.org/doc/html/rfc9562) (also known as Universally Unique IDentifiers or UUIDs).

## Usage in types

A type MAY have a field annotated with the directive.

```graphql
type Animal {
id: ID! @identity(scope: SERVICE)
name: String
}
```

Within a type, the directive MUST NOT be used on more than one field.

## Usage in interfaces

It's possible for interfaces to use the directive on a field.

```graphql
interface Identifiable {
id: ID! @identity(scope: TYPE)
}
```

Implementations of this interface MUST copy the directive to the same field. The scope argument in the implementation MUST NOT be narrower than the scope in the interface, but it may be wider.
Comment on lines +54 to +59
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure of how else to handle this, but I'm concerned this may be too constraining for many real life applications. What if you have a field of an interface type and you want to conform to that interface by multiple different types that have different fields used for identity?

Maybe that isn't too much of a concern? But it seems to me that what we really want is some way of saying "all types that implement this interface must have a field marked @identity. Not "all types that implement this interface must have a field with this specific name and type marked @identity.

I'm also not clear how this would work with fields of a union type. There was once an RFC to allow unions to require an interface implementation, but it's stalled out as of now.


## Usage in operations

It's likely that an external schema will not use this directive. In this case, an identity MAY be chosen when writing a query. The directive SHOULD NOT be included in the query sent to the GraphQL server.

```graphql
query GetAllAnimals {
allAnimals {
id @identity
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In operation usage, do you not need to provide a scope? If so, then I think there needs to be two different directives defined for FIELD and FIELD_DEFINITION.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Omitting the parameter defaults to the narrowest scope.

string
}
}
```

It is allowed for a query and a type to both use the directive to describe the same field. If the schema and client define different scopes for the same field, the widest option is used. This allows client authors to widen the scope if required.

If the server defines a field as an identity, a query SHOULD NOT choose another field. A query MUST NOT use the directive on more than one field in the same selection (unless they are in differently nested objects).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While a directive provided by schema authors is likely to be helpful in many cases. I also have a concern that it won't be 100% correct for all client use cases. There would need to be a mechanism for client developers to easily override that behavior.


## Protocol conformance

If an operation contains a field marked with the `@identity` directive (by either the schema or the operation itself), the generated SelectionSet will have a conformance to the Identifiable protocol. Since the [Swift documentation](https://developer.apple.com/documentation/swift/identifiable) states that the scope and duration of an identity is unspecified, the scope parameter is ignored when deciding to add a conformance.

```swift
// Inline selection
public struct Data: AnimalKingdomAPI.SelectionSet, Identifiable { /* ... */ }

// Fragment selection
public struct PetDetails: AnimalKingdomAPI.SelectionSet, Fragment, Identifiable { /* ... */ }
```

The protocol requires that the identity is accessible through a public field named exactly `id`. If the annotated field has a different name, an additional getter will be generated:

```swift
public var id: String { self.uuid }
```

### Naming conflicts

If the identity field is not called `id`, but another field called `id` is present in the selection, a custom getter cannot be added. Swift does not support using another field to handle the conformance.

In this case, a conformance to Identifiable SHOULD NOT be generated. Codegen should emit a warning without stopping the generation process.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a definite, unavoidable edge case. It's not ideal, but I suppose just emitting a warning and not generating the conformance is the proper behavior here.


## Caching behavior

A scope of `SELECTION` does not allow the identifier to be used as a caching key. Clients MAY use other mechanisms to determine if and how to cache the object.

An identifier with scope `TYPE` can be combined with the `__typename` field to generate a caching key that's unique for the GraphQL Service.

Identifiers with a scope of `SERVICE` or `GLOBAL` can be directly used as a caching key.
Comment on lines +106 to +108
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense, but honestly, we may continue to combine with the __typename even when the scope is SERVICE or GLOBAL, just for consistency sake.


# Alternatives

## Automatic conformance to Identifiable

The code generator could be updated to always emit a conformance to Identifiable if a scalar `id` field is present in the selection set, removing the need for a custom directive. This was suggested in Pull Request [#548](https://github.com/apollographql/apollo-ios-dev/pull/548).

## Apollo Kotlin's @typePolicy directive

Apollo Kotlin has [custom directives](https://www.apollographql.com/docs/kotlin/caching/declarative-ids) that allows for client authors to specify the caching key though pure GraphQL:

```graphql
extend type Book @typePolicy(keyFields: "id")
```

This could be used for Identifiable conformance if a single field is provided, and caching behavior could also be matched with Apollo Kotlin. A disadvantage is that this directive is only used inside type extensions, and can't be used to mark fields directly in a query.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If @typePolicy was used could the Identifiable id value be a hash value of all specified key fields? I don't have an answer for not being able to use @typePolicy in operations though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this were possible then maybe Identifiable conformance could just be a codegen configuration option (generateIdentifiableConformance: Bool) and the hashed type policy key values are used for id value?

Not great that there would be two places for configuration but it does highlight that Identifiable is a side-effect of object identity. Maybe that's not a bad requirement, plus it wouldn't need to be used in operations then?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could the Identifiable id value be a hash value of all specified key fields?

You're right, I forgot that a tuple of Hashable objects is also Hashable itself, which makes it valid for use with Identifiable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this were possible then maybe Identifiable conformance could just be a codegen configuration option (generateIdentifiableConformance: Bool)

When the identity is established as a single field named id, there is no drawback to always adding Identifiable conformance.

If a custom getter for id has to be added for conformance, then following a configuration option seems like a good idea. This would also make it less surprising when Codegen suddenly emits a warning about a getter being unable to be generated because of naming conflicts.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the identity is established as a single field named id, there is no drawback to always adding Identifiable conformance.

Even if it is a single field named id I think we'd still prefer configuration over convention. That configuration option can have a default of true if not supplied explicitly = less auto-magic behaviour.