tywalch / electrodb

A DynamoDB library to ease the use of modeling complex hierarchical relationships and implementing a Single Table Design while keeping your query code readable.
MIT License
997 stars 63 forks source link

How to get cursor for each item returned by a query? #350

Closed ides15 closed 7 months ago

ides15 commented 7 months ago

Describe the bug My project is using GraphQL and cursor-based pagination (connections). As part of implementing this kind of pagination, I want to return each item from DynamoDB with a cursor specific to that item. This is so that users can paginate starting at a specific cursor.

A GraphQL query using cursor-based pagination would look something like this:

query {
  friends(first: 2, after: "<cursor of friend 3>") { # Return a list of 2 friends, starting after friend 3
    edges {
      node {
        name
      }
      cursor # Cursor specific to this friend
    }
    pageInfo {
      endCursor # Cursor specific to the last friend in this connection
      hasNextPage
    }
  }
}

ElectroDB's query operation will return a single cursor (I believe this is the LastEvaluatedKey from DynamoDB that is then copied, stringified, and base64 encoded) for the entire item collection from DynamoDB. However, I'm trying to create a cursor for each item in the collection.

How can I do this? Looking at this util, it looks like I need the primary key and values of each entity, which I can't easily get.

I think I could use .go({ data: "raw" }) but I'd like to stay away from that if possible, since I'd need to convert back to the "nice" format of my data that ElectroDB helps with with. I could also use .go({ data: "includeKeys" }), but I don't get any type safety on the keys returned (for example, pk doesn't exist on the entity, and I'm having to use a // @ts-expect-error when referring to that, which I'd also like to stay away from).

ElectroDB Version

2.13.0

Expected behavior Just brainstorming on potential API options here, while trying to keep in mind backwards compatibility...

It'd be great to have some sort of utility function on the entity like:

const { cursor, data } = await MyEntity.query.all({ id: "blah" }).go()

const edges = data.forEach(entity => ({
  node: entity,
  cursor: MyEntity.createCursor(entity), // Returns a correctly formatted cursor for this entity
}))

return {
  edges,
  pageInfo: {
    endCursor: edges[edges.length - 1].cursor,
    hasNextPage: Boolean(cursor),
  }
}

Another option could be an addCursorForEachItem execution option:

const { cursor, data } = await MyEntity.query.all({ id: "blah" }).go({
  addCursorForEachItem: true
})

// data: [
//   {  item: { ...item... },  cursor: "..." },
//   {  item: { ...item... },  cursor: "..." },
//   {  item: { ...item... },  cursor: "..." },
// ]

// compared to

const { cursor, data } = await MyEntity.query.all({ id: "blah" }).go({
  addCursorForEachItem: false
})

// data: [
//   { ...item... },
//   { ...item... },
//   { ...item... },
// ]

(writing this code out from hand, hopefully it makes sense)

CaveSeal commented 7 months ago

I had the same problem and ended up doing it manually in the past. I haven't tried this yet, but there is a utility called conversions that looks to convert an item to a cursor.

ides15 commented 7 months ago

This is perfect @CaveSeal (and @tywalch for having this in the first place 😄) thank you!

I'm able to do the following for my example above:

const { cursor, data } = await MyEntity.query.all({ id: "blah" }).go()

const edges = data.forEach(entity => ({
  node: entity,
  cursor: MyEntity.conversions.byAccessPattern.all.fromComposite.toCursor(entity, { strict: "all" })
}))

...
tywalch commented 7 months ago

Thank you @CaveSeal!! This is exactly what I would have pointed toward