Skip to content

Handle @typePolicy in unions interfaces #156

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 19, 2025
Merged

Handle @typePolicy in unions interfaces #156

merged 8 commits into from
May 19, 2025

Conversation

BoD
Copy link
Collaborator

@BoD BoD commented May 13, 2025

In this PR:

  • key fields and __typename (including fragments for possible types) are added by the Apollo compiler plugin
  • a new Cache.keyFields map is generated containing the key fields of all types
  • this can be passed to a new KeyFieldsCacheKeyGenerator which looks up the __typename in that map

TypePolicyCacheKeyGenerator is deprecated but still the default - a tradeoff to avoid a breaking change now that we're alpha, and since using the compiled fields still works in most cases.

@BoD BoD requested a review from martinbonnin as a code owner May 13, 2025 18:33
Comment on lines +15 to +25
override fun foreignSchemas(): List<ForeignSchema> {
return listOf(ForeignSchema("cache", "v0.1", cacheControlGQLDefinitions))
}

override fun schemaListener(): SchemaListener {
return CacheSchemaListener(environment)
}

override fun documentTransform(): DocumentTransform {
return AddKeyFieldsDocumentTransform()
}
Copy link
Contributor

Choose a reason for hiding this comment

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

That unfolds nicely 👍

@@ -55,6 +58,7 @@ apollo {
packageName.set("donotstore")
srcDir("src/commonMain/graphql/doNotStore")

@OptIn(ApolloExperimental::class)
Copy link
Contributor

Choose a reason for hiding this comment

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

Reminder to remove the @ApolloExperimental from v5

return this
}
val keyFields = schema.keyFields(parentType)
val fieldNames = parentFields + this.filterIsInstance<GQLField>().map { it.responseName() }
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we really want to use responseName() here?

{
   product {
     id: price
   }
}

Because responseName() is id, we'll skip adding the id keyfield below? Which doesn't look like what we want?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ah interesting - that was to handle that case:

{
   product {
     myId: id
   }
}

here we still want to add id.

There is a check below to disallow using aliases that clash with key fields, but this is broken. We can make this check first. Also this needs a unit test 😅

Good catch! 👍

Comment on lines 16 to 17
typeDefinitions.values.filter { it.canHaveKeyFields() }.forEach {
keyFields(it, keyFieldsCache)
Copy link
Contributor

Choose a reason for hiding this comment

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

Since the map is going to be used with object __typename, there is no need to codegen the interfaces key fields?

Which yields the question that new types (unknown at build time) will now have a bad cache key I think? (because the lookup will be empty?)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I figured we need the interface key fields for that case:

union Union1 = Type1 | Type2

interface Node @typePolicy(keyFields: "id") {
  id: ID!
}

type Type1 implements Node {
  id: ID!
}

type Type2 @typePolicy(keyFields: "serialNumber") {
  serialNumber: String!
}

When union1 returns a Type1 we need to know its key field inherited from Node.

Copy link
Contributor

Choose a reason for hiding this comment

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

Right but there is no need to add the interfaces inside the codegened map, or is there?

object Schema {
  val keyFields = mapOf(
    "Type1" to setOf("id"),
    "Type2" to setOf("serialNumber"),
    // no need to codegen "Node" here?
  )
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ah! No you're right, only types are needed.

class KeyFieldsCacheKeyGenerator(private val keyFields: Map<String, Set<String>>) : CacheKeyGenerator {
override fun cacheKeyForObject(obj: Map<String, Any?>, context: CacheKeyGeneratorContext): CacheKey? {
val typename = obj["__typename"].toString()
val keyFields = keyFields[typename] ?: return null
Copy link
Contributor

Choose a reason for hiding this comment

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

Here, if the object isn't known at compile time, I believe we get a bad cache key even though it would be technically possible?

{
  animal { 
    __typename # even if typename is not a known animal, we can still compute a cache key 
    id 
    species
  }
}

Copy link
Collaborator Author

@BoD BoD May 14, 2025

Choose a reason for hiding this comment

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

Ah good point. So we should fallback to the schema type and its interface(s), found in the compiled field.

Copy link
Contributor

Choose a reason for hiding this comment

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

If we want to support this, I think so. I think this is what iOS is doing too.

@BoD BoD force-pushed the generate-more-in-plugin branch from a89f5b1 to 7ba5909 Compare May 14, 2025 13:59
@BoD BoD merged commit ccfd406 into main May 19, 2025
2 checks passed
@BoD BoD deleted the generate-more-in-plugin branch May 19, 2025 10:23
Comment on lines +137 to +143
newSelections = if (isRoot) {
// Remove the __typename if it exists and add it again at the top, so we're guaranteed to have it at the beginning of json parsing.
// Also remove any @include/@skip directive on __typename.
listOf(buildField("__typename")) + newSelections.filter { (it as? GQLField)?.name != "__typename" }
} else {
newSelections
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Should required fields be added in the !isRoot case? I think isRoot is already adding everything needed?

  • for concrete types, it adds __typename and keyfields.
  • for abstract types, it adds __typename and inline fragments for all possible types.

I think that coveres it all? So probably we don't even need to track parentTypes ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

👀 Yeah I think that's right. I'll open a fix.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I remember now, it was to not add new fragments and instead re-use existing ones.

original:

query GetUser {
  user {
    ... on Person {
      name
    }
  }
}

if we only add fields in isRoot:

query GetUser {
  user {
    __typename
    ... on Person {
      name
    }
    ... on Robot {
      serialNumber
    }
    ... on Person {
      id
    }
  }
}

if we also do it in !isRoot:

query GetUser {
  user {
    __typename
    ... on Person {
      name
      id
    }
    ... on Robot {
      serialNumber
    }
  }
}

In the end in the generated code the fragments are collapsed, so we'll still get only one onPerson, right? But this removes a bit of duplication in the sent document. Not sure if it's better to be 'clever' or simple here :)

Copy link
Contributor

Choose a reason for hiding this comment

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

Aren't the inline fragments unconditionally added in the root of each field?

    // Unions and interfaces without key fields: add key fields of all possible types in inline fragments
    val inlineFragmentsToAdd = if (isRoot && keyFields.isEmpty()) {

Copy link
Contributor

@martinbonnin martinbonnin May 30, 2025

Choose a reason for hiding this comment

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

I like the consistency of adding the inline fragments always.

PS: it would be nice to have a comment this field was automatically added by the cache compiler plugin (but I'm not sure we can do it at the moment because our AST doesn't support comments)

Copy link
Collaborator Author

@BoD BoD May 30, 2025

Choose a reason for hiding this comment

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

Aren't the inline fragments unconditionally added

alreadyHandledTypes (maybe badly named 😬) is the trick

consistency

Yeah that may be a bit more obvious for people looking at the resulting document 👍 . And agreed about a comment would be nice!

Copy link
Contributor

Choose a reason for hiding this comment

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

alreadyHandledTypes is the trick

Ah! I see it now, thanks! I would vote for consistent/simple. We could in the future, introduce another transform that merges fields/inline fragments to reduce the document size (although I believe the "good" solution there is probably PQs)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

-> #166

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants