Skip to content

Instantly share code, notes, and snippets.

@Mando75
Last active March 7, 2020 23:44
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Mando75/a94f295bca421aff4db34af5d234018b to your computer and use it in GitHub Desktop.
Save Mando75/a94f295bca421aff4db34af5d234018b to your computer and use it in GitHub Desktop.
An example PaginationHelper for typeorm graphql loader
import { FieldNode, GraphQLResolveInfo, SelectionNode } from "graphql";
import { GraphQLDatabaseLoader } from "@mando75/typeorm-graphql-loader";
import { SearchOptions, FeedNodeInfo } from "@mando75/typeorm-graphql-loader/dist/types";
import { LoaderSearchMethod } from "@mando75/typeorm-graphql-loader/dist/base";
import { OrderByCondition } from "typeorm";
type getFeedOptions = {
search?: SearchOptions;
order?: OrderByCondition;
};
/**
* Helper class to assist with pagination
*/
export class PaginationHelper {
defaultFeedOffset: number;
defaultFeedLimit: number;
maxFeedLimit: number;
entityLoader: GraphQLDatabaseLoader;
constructor(entityLoader: GraphQLDatabaseLoader) {
this.maxFeedLimit = 15;
this.defaultFeedLimit = this.maxFeedLimit;
this.defaultFeedOffset = 0;
this.entityLoader = entityLoader;
}
public static getDefaultSearchOptions(
searchText: string | null | undefined,
columns: Array<string | Array<string>>
): SearchOptions | undefined {
if (!searchText) return undefined;
else {
return {
searchText,
searchMethod: LoaderSearchMethod.ANY_POSITION,
caseSensitive: false,
searchColumns: columns
};
}
}
/**
* Validate feed query options. Will check for
* null or invalid options and reset to default
* @param offset
* @param limit
*/
public validateFeedOptions(offset: number | null | undefined, limit: number | null | undefined) {
// default min
if (!offset || offset < 0) {
offset = this.defaultFeedOffset;
}
// default and max limit
if (!limit || limit < 0 || limit > this.maxFeedLimit) {
limit = this.defaultFeedLimit;
}
return { limit, offset };
}
/**
* Helper method to get the next feed params
* @param pagination
* @param count
*/
public getNextFeedOffset(pagination: { offset: number; limit: number }, count: number) {
const { offset, limit } = this.validateFeedOptions(pagination.offset, pagination.limit);
const nextOffset = offset + limit;
const recordsLeft = count - nextOffset;
const newOffset = recordsLeft < 1 ? count : nextOffset;
return {
offset: newOffset,
hasMore: newOffset !== count
};
}
/**
* Finds a single node in the GraphQL AST to return the feed info for
* @param info
* @param fieldName
*/
public getFeedNodeInfo(info: GraphQLResolveInfo, fieldName: string): FeedNodeInfo {
const childFieldNode = info.fieldNodes
.map(node => (node.selectionSet ? node.selectionSet.selections : []))
.flat()
.find((selection: SelectionNode) =>
selection.kind !== "InlineFragment" ? selection.name.value === fieldName : false
) as FieldNode;
const fieldNodes = [childFieldNode];
return { fieldNodes, fragments: info.fragments, fieldName };
}
public async getFeed<T>(
entity: Function,
feed: GQL.IFeedParams,
where: Partial<T>,
info: GraphQLResolveInfo,
fieldName: string,
options?: getFeedOptions
) {
const search = options ? options.search : undefined;
const order = options ? options.order : undefined;
const pagination = this.validateFeedOptions(feed.offset, feed.limit);
const [records, count] = await this.entityLoader.loadManyPaginated<T>(
entity,
where,
this.getFeedNodeInfo(info, fieldName),
pagination,
{ search, order }
);
const result: { [k: string]: Array<T> | number | boolean } = {
...this.getNextFeedOffset(pagination, count)
};
result[fieldName] = records;
return result;
}
}
@Mando75
Copy link
Author

Mando75 commented Jan 28, 2020

Example usage:

GraphQL Types

  "A Feed is meant to be used for 'endless scrolling' and does not provide any backwards pagination capability"
  interface Feed {
    "The next pagination offset to be used"
    offset: Int!
    "Whether or not there are more records to fetch. Pagination should stop after hasMore is false"
    hasMore: Boolean!
  }

  "Used for providing feed pagination parameters"
  input FeedParams {
    "How many records to offset by"
    offset: Int!
    "How many records to return. Default is 15, max is 15"
    limit: Int!
  }

  input PlayerFeedFilter {
    "Searchable fields: [firstName, lastName, email]"
    search: InputString
    "Filter by group id"
    groupId: ID
  }

  "Get feed paginated Players"
  type PlayerFeed implements Feed {
    "Feed of player records"
    players: [Player]!
    hasMore: Boolean!
    offset: Int!
  }

type Query {
    "Get a feed of players belonging to a given group"
    groupPlayerFeed(
      "Specify which group's players to return"
      groupId: ID!
      "See FeedParams documentation"
      feed: FeedParams!
      "Use this to apply filters to the query"
      filters: PlayerFeedFilter
    ): PlayerFeed
}

Example resolver

import { Resolver, searchFilterResult } from "../../../types/graphql-utils";
import { Player } from "../../../entity/Player";
import { PaginationHelper } from "../../../utils/paginationHelper";

export const getGroupPlayerFeed: Resolver = async (
  _,
  { groupId, feed, filters }: GQL.IGroupPlayerFeedOnQueryArguments,
  { paginationHelper },
  info
) => {
  const { search, where } = applyFilters(filters, groupId);
/*
 * This is the important part. Currently, our resolve info is giving us the PlayerFeed type
 * which we don't want to try to resolve via the loader. Instead, we want the loader to resolve
 * just the players field on the PlayerFeed type. To do this, we pass the pagination helper instance we
 * got from the context, and tell it to extract the "players" field from the info selection set. This will make
 * sure that the loader only tries to fetch data for an actual Player entity and not the pagination info. 
 */
  return paginationHelper.getFeed<Player>(Player, feed, where, info, "players", {
    search: PaginationHelper.getDefaultSearchOptions(search, Player.SEARCHABLE_FIELDS)
  });
};

const applyFilters = (
  filters: GQL.IPlayerFeedFilter | undefined | null,
  groupId: string
): searchFilterResult<Player> => {
  const { search } = filters ? filters : { search: null };

  // This query requires a groupId, so there is no point
  // in checking if the filter has it
  let where: Partial<Player> = { groupId };
  return { search, where };
};

@Mando75
Copy link
Author

Mando75 commented Jan 28, 2020

Basically, you are able to pass the name of the field you are paginating to the helper and it will extract the child ResolveInfo nodes for you.

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