Connection helpers

Paginate attachment rows while returning Media nodes and custom edge fields

An image can appear in several posts, with a different caption in each. The Media row describes the image; the PostMedia row describes its attachment to a post. A connection can expose the image as its node and the attachment's caption as an edge field:

query PostAttachments {
  author(id: 1) {
    postsConnection(first: 1) {
      nodes {
        title
        attachments(first: 2) {
          edges {
            caption
            node {
              url
              uploadedBy {
                fullName
              }
            }
          }
          pageInfo {
            endCursor
            hasNextPage
          }
        }
      }
    }
  }
}

t.relatedConnection handles a relation whose rows are the connection's nodes. Use drizzleConnectionHelpers with t.connection when the rows being paginated differ from those nodes, or when you need to load the rows yourself. The helper supplies the selection, cursor, and result handling that a normal Relay connection field does not have.

Select the nodes through the attachment

Import drizzleConnectionHelpers from @pothos/plugin-drizzle and use the builder configured with the Drizzle and Relay plugins. The helper targets postMedia. Its select uses nestedSelection() to request the Media fields selected beneath the connection's nodes; resolveNode returns that related Media row:

const attachmentArgs = builder.args((t) => ({
  hasCaption: t.boolean({ defaultValue: false }),
}));

const attachments = drizzleConnectionHelpers(builder, 'postMedia', {
  args: () => attachmentArgs,
  query: (args) => ({
    orderBy: { id: 'asc' },
    where: args.hasCaption ? { caption: { isNotNull: true } } : {},
  }),
  select: (nestedSelection) => {
    return {
      columns: { caption: true },
      with: { media: nestedSelection() },
    };
  },
  resolveNode: (attachment) => attachment.media,
});

The connection orders and creates cursors from attachment IDs, not Media IDs. An image shared by two posts can therefore have a different caption and connection position in each post. The nested uploader selection is still planned from the requested Media fields.

The helper's args and query options define a hasCaption argument. When it is true, the connection includes only attachments with a caption. Its default, false, includes all attachments. getArgs() exposes that argument on the field, and getQuery() combines the filter and ordering with the requested page and node selection.

Add the connection and its edge fields

Media below is the object ref returned by builder.drizzleObject('media', ...). A normal t.connection needs an explicit select to load the helper's query on the parent. Its resolver passes the selected attachment rows to the helper's resolve method:

builder.drizzleObjectField('posts', 'attachments', (t) =>
  t.connection(
    {
      type: Media,
      args: attachments.getArgs(),
      select: (args, ctx, nestedSelection) => {
        return {
          with: {
            attachments: attachments.getQuery(args, ctx, nestedSelection),
          },
        };
      },
      resolve: (post, args, ctx) => {
        return attachments.resolve(post.attachments, args, ctx);
      },
    },
    {},
    {
      fields: (edge) => ({
        caption: edge.string({
          nullable: true,
          resolve: (attachment) => attachment.caption,
        }),
      }),
    },
  ),
);

The third argument to t.connection configures the Edge type. Each edge retains the selected attachment fields, so its caption resolver reads the attachment's caption, while node returns the Media selected by resolveNode. A missing caption returns null.

The post has three attachments. Its first unfiltered page of two has hasNextPage: true; filtering to captioned attachments returns two and has no next page. Pagination uses the same first/after and last/before arguments as other connections.

Querying the rows in a resolver

The same helper can build a query for a resolver that fetches the attachment rows itself. Pass GraphQL resolve info to getQuery, merge any additional filter with its cursor filter, and pass the result to resolve:

// Alternative resolver for the Post.attachments connection above:
resolve: async (post, args, ctx, info) => {
  const query = attachments.getQuery(args, ctx, info);
  const attachmentRows = await db.query.postMedia.findMany({
    ...query,
    where: {
      AND: [query.where ?? {}, { postId: post.id }],
    },
  });

  return attachments.resolve(attachmentRows, args, ctx);
},

This alternative needs db imported from the application's database module and id selected on Post. Remove the field's relation select when using it, so the rows are not also loaded through the parent query. Replacing the helper's where rather than combining it would discard pagination constraints.

If the rows and nodes are the same type, omit resolveNode and the node-mapping select when creating the helper. Its ref provides the node type for t.connection. Prefer t.relatedConnection when you can expose that relation directly without custom loading.