Skip to content
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

How to create cache id when paginating #4251

Closed
vrrashkov opened this issue Jul 8, 2022 · 16 comments
Closed

How to create cache id when paginating #4251

vrrashkov opened this issue Jul 8, 2022 · 16 comments

Comments

@vrrashkov
Copy link

I have the following schema

image

Every time I want to paginate through the medications I pass different number for the period argument so that means every time I call for medications it creates new Cache ID so my cache looks something like.

  • Patient:12.medications({"period":10})
  • Patient:12.medications({"period":20})
  • Patient:12.medications({"period":30})

This is a problem not only because it creates multiple cache results but also I cannot "watch()" the query so when I insert/update/delete it changes.

What I want to achieve is no matter what the period is the cache Id should always be something general like.

Patient:12.medications

I have tried doing something like that, just passing null to keyArgs but nothing happens it still uses the period.

extend type Patient @fieldPolicy(forField: "medications", keyArgs: null)

I also tried doing it programmatically but again, nothing is changing:

image

Not sure what am I missing and how should I approach this.

@BoD
Copy link
Contributor

BoD commented Jul 8, 2022

Hi!

We're currently improving the caching/pagination aspect of the library, and this use-case is specifically something we want to support!

It would be interesting to see if the experimental APIs we recently added can address your need, but I must warn they are still pretty rough.

Automatic way (experimental!)

If you’d like to try it:

  • configure your project to use the snapshots
  • replace the dependency of com.apollographql.apollo3:apollo-normalized-cache to com.apollographql.apollo3:apollo-normalized-cache-incubating and of com.apollographql.apollo3:apollo-normalized-cache-sqlite to com.apollographql.apollo3:apollo-normalized-cache-sqlite-incubating
  • in your extra.graphqls declare the pagination arguments (that is, arguments that should be dropped when computing the key for the cached value - in your case that is period):
extend type Patient
@fieldPolicy(forField: "medications", paginationArgs: "period")
  • declare a MetadataGenerator in order to recognize the field to merge:
private class MedicationsMetadataGenerator : MetadataGenerator {
    override fun metadataForObject(obj: Any?, context: MetadataGeneratorContext): Map<String, Any?> {
      if (context.field.name == "medications") {
        return mapOf("merge" to true)
      }
      return emptyMap()
    }
  }
  • declare a FieldMerger in order to merge pages from the network with pages already in the cache:
private class MedicationsFieldMerger : FieldRecordMerger.FieldMerger {
    override fun mergeFields(existing: FieldRecordMerger.FieldInfo, incoming: FieldRecordMerger.FieldInfo): FieldRecordMerger.FieldInfo {
      return if (!existing.metadata.containsKey("merge") || !incoming.metadata.containsKey("merge")) {
        incoming
      } else {
        val existingList = existing.value as List<*>
        val incomingList = incoming.value as List<*>
        val mergedList = existingList + incomingList
        val mergedMetadata = mapOf("merge" to true)
        FieldRecordMerger.FieldInfo(
            value = mergedList,
            metadata = mergedMetadata
        )
      }
    }
  }
  • and finally, use these when configuring your cache:
client = ApolloClient.Builder()
        .serverUrl(...)
        .normalizedCache(
            normalizedCacheFactory = ...,
            cacheKeyGenerator = TypePolicyCacheKeyGenerator,
            metadataGenerator = MedicationsMetadataGenerator(),
            apolloResolver = FieldPolicyApolloResolver,
            recordMerger = FieldRecordMerger(MedicationsFieldMerger()),
        )
        .build()

With that, pages should merged in one array and be stored in the cache under one entry, that you can watch.

Manual way

You can use the ApolloStore APIs to manually update the cache with your own manually merged version of the data, right after you retrieve a new page, with a NetworkOnly policy. Users have used this approach in the past.

Please don't hesitate tell us what works / doesn't work as this is currently a hot topic for us 😊

@vrrashkov
Copy link
Author

Thank you for the fast and descriptive response. I will definitely will check both ways and see what suits my needs in this case.

@nikonhub
Copy link

Hello,

I'm trying to make it work on Query level. All seems to go well.

  • "after" field is removed from cache
  • I'm seeing my "posts" query in metadataForObject()
  • But it nevers makes it to mergeFields()

Am I missing something. Or mergeFields does not work on Query ?

extend type Query @fieldPolicy(forField: "posts", paginationArgs: "after")
override fun metadataForObject(obj: Any?, context: MetadataGeneratorContext): Map<String, Any?> {
    if (context.field.name == "posts") {
        return mapOf("merge" to true)
    }
override fun mergeFields(existing: FieldRecordMerger.FieldInfo, incoming: FieldRecordMerger.FieldInfo): FieldRecordMerger.FieldInfo {
// Never resolve with posts here

@BoD
Copy link
Contributor

BoD commented Jan 24, 2023

Hi @nikonhub !
For it to work as expected you will also need to mark the posts field as embedded in the query record (instead of being saved as their own records), with a @typePolicy directive:

extend type Query
@fieldPolicy(forField: "posts", paginationArgs: "after")
@typePolicy(embeddedFields: "posts")

By the way, if your posts field follows the Relay style pagination (it has first, after, and optionally last, before fields), then there is a simpler way to do this now:

extend type Query
@typePolicy(connectionFields: "posts")
ApolloClient.Builder()
    .serverUrl("")
    .normalizedCache(
        normalizedCacheFactory = cacheFactory,
        cacheKeyGenerator = TypePolicyCacheKeyGenerator,
        metadataGenerator = ConnectionMetadataGenerator(Pagination.connectionTypes), // Pagination.connectionTypes is generated
        apolloResolver = FieldPolicyApolloResolver,
        recordMerger = ConnectionRecordMerger,
    )
    .build()

This is still experimental and not documented, but an example is here.

@nikonhub
Copy link

Thanks for quick reply.

I've tried different things. But none worked. I've checked examples here and the source code of ConnectionMetadatGenerator + ConnectionRecordMerger.

Then even though I don't use Relay style, I've tried to add connectionFields argument, and got a build error

Unknown argument `connectionFields` on directive 'typePolicy'

So now I'm thinkg maybe something wrong in the model generation phase and embeddedFields in typePolicy are not even taken into account.

apollo_version = 4.0.0-SNAPSHOT (also tried 3.7.4)

    implementation "com.apollographql.apollo3:apollo-runtime:$apollo_version"
    implementation "com.apollographql.apollo3:apollo-api:$apollo_version"
    implementation("com.apollographql.apollo3:apollo-adapters:$apollo_version")
    implementation("com.apollographql.apollo3:apollo-normalized-cache-incubating:$apollo_version")
    implementation("com.apollographql.apollo3:apollo-normalized-cache-sqlite-incubating:$apollo_version")

Sample of schema :

interface PagingObject {
    data: [Content]
    paging: Paging
}

union Content = Post | ...

type PostPagingObject implements PagingObject {
    data: [Post!]
    paging: Paging
}

type Query 
@typePolicy(embeddedFields: "followingCommunitiesPosts")
@fieldPolicy(forField: "followingCommunitiesPosts", paginationArgs: "after")
{
    followingCommunitiesPosts(after: String, countryCode: String, latitude: Float, longitude: Float, maxDistance: Float): PostPagingObject
    ...

Sample of cache dump :

  "followingCommunitiesPosts({"countryCode":"fr","latitude":null,"longitude":null,"maxDistance":null}).paging" : {
    "__typename" : Paging
    "cursors" : CacheKey(followingCommunitiesPosts({"countryCode":"fr","latitude":null,"longitude":null,"maxDistance":null}).paging.cursors)
  }

  "QUERY_ROOT" : {
    "followingCommunitiesPosts({"countryCode":"fr","latitude":null,"longitude":null,"maxDistance":null})" : {data=[CacheKey(followingCommunitiesPosts({"countryCode":"fr","latitude":null,"longitude":null,"maxDistance":null}).data.0), CacheKey(followingCommunitiesPosts({"countryCode":"fr","latitude":null,"longitude":null,"maxDistance":null}).data.1), CacheKey(followingCommunitiesPosts({"countryCode":"fr","latitude":null,"longitude":null,"maxDistance":null}).data.2), CacheKey(followingCommunitiesPosts({"countryCode":"fr","latitude":null,"longitude":null,"maxDistance":null}).data.3), CacheKey(followingCommunitiesPosts({"countryCode":"fr","latitude":null,"longitude":null,"maxDistance":null}).data.4), CacheKey(followingCommunitiesPosts({"countryCode":"fr","latitude":null,"longitude":null,"maxDistance":null}).data.5), CacheKey(followingCommunitiesPosts({"countryCode":"fr","latitude":null,"longitude":null,"maxDistance":null}).data.6), CacheKey(followingCommunitiesPosts({"countryCode":"fr","latitude":null,"longitude":null,"maxDistance":null}).data.7), CacheKey(followingCommunitiesPosts({"countryCode":"fr","latitude":null,"longitude":null,"maxDistance":null}).data.8), CacheKey(followingCommunitiesPosts({"countryCode":"fr","latitude":null,"longitude":null,"maxDistance":null}).data.9)], paging=CacheKey(followingCommunitiesPosts({"countryCode":"fr","latitude":null,"longitude":null,"maxDistance":null}).paging)}
  }

@BoD
Copy link
Contributor

BoD commented Jan 24, 2023

Sorry I forgot to warn that to use connectionFields you must opt-in with:

extend schema @link(url: "https://specs.apollo.dev/kotlin_labs/v0.2", import: ["@typePolicy", "@fieldPolicy"])

That explains the error you got - but if you you're not using connectionFields you don't need that.

The dump actually looks almost good:

  • the value for followingCommunitiesPosts is embedded in QUERY_ROOT instead of having its own record
  • the after argument is not part of the key

But you should also embed PostPagingObject's fields:

type PostPagingObject @typePolicy(embeddedFields: "data, paging") {
    data: [Post!]
    paging: Paging
}

that way you'll be able to get their values in your mergeFields implementation.

@nikonhub
Copy link

Thank you a lot ! EmbeddedFields on PostPagingObject was the missing part.

@nikonhub
Copy link

Hello,

Actually I'm rewriting a react native app to native. The basic pattern here is to query network + watch cached data. A later mutation would update all queries watching the changed object. Because data is normalized.

This is also the kotlin default behavior. Each item has it's own entry. And mutating an object also updates queries.

After adding embeddedFields I haven't realized what were the consequences. Each query now don't reference a stand alone post entry. But keeps all data inside. So it's not possible to mutate objects anymore and watch at the same time.

In JS it works almost out of the box with minimal configuration. All fields are "eligible" to go through a custom merge function

        Query: {
          fields: {
            followingCommunitiesPosts: {
              keyArgs: ['countryCode'],
              merge: (previous: any, incoming: any, { args }: any) => {
                const { after } = args || {};
                if (after) {
                  return Paging.mergePagingObjects(previous, incoming, {
                    after,
                  });
                }

                return incoming;
              },
            },

I'm wondering now if it's the right pattern to use with kotlin lib. And even if it's possible to keep all objects as stand alone and customize the merge function for pagination at the same time

@BoD
Copy link
Contributor

BoD commented Jan 30, 2023

Hi @nikonhub ! I’m expecting watch to work as intended when using embeddedFields but I’m probably missing something and it’s a bit difficult to follow without a concrete example - would it be possible for you to create a minimal project demonstrating what doesn’t work in your case?

@nikonhub
Copy link

I'll try to explain from the cache perspective. It should be much clearer

Use case :

  • Step 1 : Query followingPosts (Query)
  • Step 2 : upvotePost (Mutation)

Without embeddedFields (The default behavior is each entity has it's own record in the database.)

Step 1

KEY : followingPosts({"countryCode":"ABC"})
RECORD:  {
   "data":
     [
       "ApolloCacheReference{Post:c9ef0915-2240-4dab-8325-a8d66ffefca2}",
       "ApolloCacheReference{Post:e836c093-e6f7-4a28-b7e8-b1aa565ddaa0}",
       "ApolloCacheReference{Post:045ba7cb-697e-41d2-a0eb-c187b55d8f51}",
       "ApolloCacheReference{Post:4ce29af0-a8d4-44f6-b013-57eda45c1e23}",
       ....
     ],
   "paging": 'ApolloCacheReference{followingPosts({"countryCode":"ABC"}).paging}'
 }
...
KEY :  Post:c9ef0915-2240-4dab-8325-a8d66ffefca2
RECORD:  {
   "__typename": "Post",
   "id": "c9ef0915-2240-4dab-8325-a8d66ffefca2",
   "body": "Content",
   "image": null,
   "author": "ApolloCacheReference{User:0894f298-ea96-4426-aeb5-08d4f61da2d3}",
   "vote": "neutral",
 }

Step 2

  • upvotePost(Post:c9ef0915-2240-4dab-8325-a8d66ffefca2)
  • Record is updated with "vote" : "upvote"
  • followingPosts.watch collects new data

With embeddedFields

extend type Query
@fieldPolicy(forField: "followingPosts", paginationArgs: "after")
@typePolicy(embeddedFields: "followingPosts")

extend type PostPagingObject
@typePolicy(embeddedFields: "data paging")

extend type Paging
@typePolicy(embeddedFields: "cursors")

Step 1

KEY : QUERY_ROOT
RECORD : {
  "followingPosts({"countryCode":"ABC"})": {
    "data": [
      {
        "__typename": "Post",
        "id": "c9ef0915-2240-4dab-8325-a8d66ffefca2",
        "body": "Content",
        "image": null,
        "author": "ApolloCacheReference{User:0894f298-ea96-4426-aeb5-08d4f61da2d3}",
        "voteType": "neutral"
      },
      {
         "__typename": "Post",
         "id": "e836c093-e6f7-4a28-b7e8-b1aa565ddaa0",
         ...
       }
    ],
    "paging": {
      "__typename": "Paging",
      "cursors": {
        "before": "MTY3MzgxNzIyNDM0MA==",
        "after": "MTY3MTU0NTQ5OTA5NA=="
      }
    }
}

Step 2

  • upvotePost(Post:c9ef0915-2240-4dab-8325-a8d66ffefca2)
  • But there is no stand alone Post:c9ef0915-2240-4dab-8325-a8d66ffefca2 record
  • A new one is created. But it's not the one referenced by followingPosts watchers

PS : Writing this response I've managed to achieve the expected result...

Only embed "paging" without "data". It works in my case because "paging" has enough information to merge 2 objects

extend type PostPagingObject
@typePolicy(embeddedFields: "paging")
KEY : QUERY_ROOT
RECORD:{
  'followingPosts({"countryCode":"ABD"})':
    {
      "data":
        [
          "ApolloCacheReference{Post:c9ef0915-2240-4dab-8325-a8d66ffefca2}",
          "ApolloCacheReference{Post:e836c093-e6f7-4a28-b7e8-b1aa565ddaa0}",
          ...
        ],
      "paging":
        {
          "__typename": "Paging",
          "cursors":
            {
              "before": "MTY3MzgxNzIyNDM0MA==",
              "after": "MTY2NTEzNjYzMTIzMA==",
            },
        },
    },
}

Meanwhile I've resolved to manually merge objects :
- Querying with doNotStore
- Manually readOperation from apolloStore
- Merging
- Writing back to apollo store

But even though the apolloStore readOperation/writeOperation were done on the IO thread it seemed slower (than the mergeFields) and was really cumbersome to call a merge function for each query with pagination.
Now it goes through the mergeFields method 👍

@BoD
Copy link
Contributor

BoD commented Jan 30, 2023

Thanks a lot for the explanation and feedback 🙏 That all makes sense. Embedding only what's necessary was the right call, and I'm glad it works out for you now!

@BoD
Copy link
Contributor

BoD commented Feb 24, 2023

Closing this one for now, don't hesitate to open a new one if other questions come up.

@BoD BoD closed this as completed Feb 24, 2023
@ArjanSM
Copy link
Contributor

ArjanSM commented Dec 21, 2023

Hi @BoD 👋
Can I use the ConnectionMetadataGenerator and the ConnectionRecordMerger with a custom CacheKeyGenerator?
For e.g I've this query

query CommunityQuestions($businessId: String,
    $after: String,
    $first: Int) {
    business(encid: $businessId) {
        id
        communityQuestions(after: $after,
            first: $first) {
        __typename
            pageInfo {
              hasNextPage,
              endCursor
              startCursor
            }
            totalCount
            edges {
              __typename
                node {
                  __typename
                    __typename
                    id
    		    platformSource
    		    text
                }
            }
        }
    }
}

My Normalized Cache has been setup as follows:-

ApolloClient.Builder()
            .serverUrl(url)
            .normalizedCache(
                loggingCacheFactory,
                cacheKeyGenerator = cacheKeyGenerator,
                metadataGenerator = ConnectionMetadataGenerator(setOf("BusinessCommunityQuestionConnection")), // I can explain why I did this.
                apolloResolver = FieldPolicyApolloResolver,
                recordMerger = ConnectionRecordMerger,
            )
            .build()

The cacheKeyGenerator creates CacheKeys by appending id to the __typename for fields that have an id defined in the schema.
The extra.graphqls looks something like this:

extend schema @link(url: "https://specs.apollo.dev/kotlin_labs/v0.2", import: ["@typePolicy", "@fieldPolicy"])

extend type Query
@typePolicy(connectionFields: "communityQuestions")

When I run the above query the root field for the query i.e Business is merged in the cache as follows:-

//MemoryCache dump
 "Business| mna9EkKmeoHeBdZVmCbEkbtFwRdo" : {
    "__typename" : Business
    "id" : mna9EkKmeoHeBdZVmCbEkbtFwRdo
    "communityQuestions({"first":20})" : CacheKey(Business| mna9EkKmeoHeBdZVmCbEkbtFwRdo.communityQuestions("first":20))
  }

when I request for the second page of the query the cache dump for the root field looks as:-

 "Business| mna9EkKmeoHeBdZVmCbEkbtFwRdo" : {
    "__typename" : Business
    "id" : mna9EkKmeoHeBdZVmCbEkbtFwRdo
    "communityQuestions({"first":20})" : CacheKey(Business| mna9EkKmeoHeBdZVmCbEkbtFwRdo.communityQuestions("first":20))
"communityQuestions({"after":"eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoib2Zmc2V0Iiwib2Zmc2V0IjoxOX0","first":20})" : CacheKey(Business| mna9EkKmeoHeBdZVmCbEkbtFwRdo.communityQuestions({"after":"eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoib2Zmc2V0Iiwib2Zmc2V0IjoxOX0","first":20})
  }

Is this the desired way of merging multiple pages?

@BoD
Copy link
Contributor

BoD commented Dec 22, 2023

@ArjanSM The pagination arguments are not dropped and the records are not merged. It looks like it is because the @typePolicy is on Query for the communityQuestions field, whereas from what I can see this field is not on Query but on Business. So you should probably move the it there:

extend type Business
@typePolicy(connectionFields: "communityQuestions")

Let me know if that works better.

@ArjanSM
Copy link
Contributor

ArjanSM commented Jan 3, 2024

@BoD Thanks. After extending the Business type to include connectionFields I am seeing all the edges stored together.

"Business|mna9EkKmeoHeBdZVmCbEkbtFwRdo" : {
    "__typename" : Business
    "id" : mna9EkKmeoHeBdZVmCbEkbtFwRdo
    "communityQuestions" : {
    __typename=BusinessCommunityQuestionConnection, 
    pageInfo=CacheKey(Business|mna9EkKmeoHeBdZVmCbEkbtFwRdo.communityQuestions.pageInfo), 
    totalCount=30, 
    edges=[
        {__typename=BusinessCommunityQuestionEdge, cursor=eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoib2Zmc2V0Iiwib2Zmc2V0IjowfQ, node=CacheKey(BusinessCommunityQuestion|9pwpfVZFt1QCVUD5HaOulw)}, 
        {__typename=BusinessCommunityQuestionEdge, cursor=eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoib2Zmc2V0Iiwib2Zmc2V0IjoxfQ, node=CacheKey(BusinessCommunityQuestion|4xKGC1dXylG7n5i83rXZvQ)}, 
        {__typename=BusinessCommunityQuestionEdge, cursor=eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoib2Zmc2V0Iiwib2Zmc2V0IjoyfQ, node=CacheKey(BusinessCommunityQuestion|ESrR_jLxt3Jq7Q6QMSr2lA)}, 
        {__typename=BusinessCommunityQuestionEdge, cursor=eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoib2Zmc2V0Iiwib2Zmc2V0IjozfQ, node=CacheKey(BusinessCommunityQuestion|xiPT9-9am1XlzvUx-KnaYQ)}, 
        {__typename=BusinessCommunityQuestionEdge, cursor=eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoib2Zmc2V0Iiwib2Zmc2V0Ijo0fQ, node=CacheKey(BusinessCommunityQuestion|q1izTXKUUcZ8kW1HqtUEVA)}
        ]
    }
}

The one thing which may be in interest of developers is to include the cursor field associated with edges in the operation.
I wanted to enquire if there are any opinions on having an API which helps provide the names of paginated arguments programmatically?
or if there were a way to update the isPagination property of a CompiledArgument programmatically?

@BoD
Copy link
Contributor

BoD commented Jan 8, 2024

are any opinions on having an API which helps provide the names of paginated arguments programmatically?
or if there were a way to update the isPagination property of a CompiledArgument programmatically?

At the moment this can only be done declaratively but it would probably make sense to have a programmatic API as well. Did you have a specific use-case for this? Don't hesitate to create a new ticket for this (as this one is closed) 🙏

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

No branches or pull requests

4 participants