import { AssertionError } from 'assert'; import { ApolloError } from 'apollo-server'; import { ConnectionArguments, getOffsetWithDefault, offsetToCursor, } from 'graphql-relay'; import { FindManyOptions, ObjectLiteral, Repository, SelectQueryBuilder, } from 'typeorm'; import { Connection } from './Connection'; export type ConnectionFromRepositoryOptions = ConnectionArguments & Pick< FindManyOptions, 'where' | 'relations' | 'join' | 'cache' | 'withDeleted' | 'order' > & { /** * The max amount of results to return, even if "first" or "last" are unused * @default 50 */ limit?: number; }; const DEFAULT_CONNECTION_FROM_REPOSITORY_OPTIONS = { limit: 50, }; const sanitizeOptions = (options: ConnectionFromRepositoryOptions) => { const { limit, before, after, first: firstArg, last: lastArg, } = { ...DEFAULT_CONNECTION_FROM_REPOSITORY_OPTIONS, ...options, }; if (before && (after || firstArg)) { throw new ApolloError( 'Pagination cannot use "before" with "after" or "first"' ); } if (after && (before || lastArg)) { throw new ApolloError( 'Pagination cannot use "after" with "before" or "last"' ); } if (firstArg && (before || lastArg)) { throw new ApolloError( 'Pagination cannot use "first" with "before" or "last"' ); } if (lastArg && (after || firstArg)) { throw new ApolloError( 'Pagination cannot use "last" with "after" or "first"' ); } // limited how much gets taken const first = limit && firstArg ? Math.min(firstArg, limit) : firstArg; const last = limit && lastArg ? Math.min(lastArg, limit) : lastArg; return { first, last, before, after, limit, }; }; type Offsets = { startOffset: number; afterOffset: number; beforeOffset: number; endOffset: number; }; const getOffsets = ({ before, totalCount, after, first, last, limit, }: Pick< ConnectionFromRepositoryOptions, 'before' | 'after' | 'first' | 'last' > & { totalCount: number; limit: number }): Offsets => { // offsets const beforeOffset = getOffsetWithDefault(before, totalCount); const afterOffset = getOffsetWithDefault(after, -1); let startOffset = Math.max(-1, afterOffset) + 1; let endOffset = Math.min(beforeOffset, totalCount); // these options are all mutually exclusive if (first) { // set the end to add another `first` amount endOffset = Math.min(endOffset, startOffset + first); } else if (last) { // set the start to start at a prior `last` amount startOffset = Math.max(startOffset, endOffset - last); } else if (!first && !last) { // with nothing, just take the first `limit` endOffset = Math.min(endOffset, startOffset + limit); } return { startOffset, afterOffset, beforeOffset, endOffset, }; }; const getSkipAndTake = ({ startOffset, endOffset, }: Offsets): { skip: number; take: number } => { const skip = Math.max(startOffset, 0); // sql offset const take = Math.max(endOffset - startOffset, 1); // sql limit return { skip, take, }; }; const getConnection = ({ first, last, totalCount, after, before, entities, startOffset, endOffset, beforeOffset, afterOffset, }: ConnectionFromRepositoryOptions & Omit & { totalCount: number; entities: T[]; }): Connection => { const edges = entities.map((entity, index) => ({ cursor: offsetToCursor(startOffset + index), node: entity, })); // page info const { length, 0: firstEdge, [length - 1]: lastEdge } = edges; const lowerBound = after ? afterOffset + 1 : 0; const upperBound = before ? Math.min(beforeOffset, totalCount) : totalCount; const pageInfo = { startCursor: firstEdge ? firstEdge.cursor : null, endCursor: lastEdge ? lastEdge.cursor : null, hasPreviousPage: startOffset > lowerBound, hasNextPage: endOffset < upperBound, }; const count = edges.length; return { edges, nodes: entities, pageInfo, totalCount, count, }; }; export const connectionFromRepository = async ( options: ConnectionFromRepositoryOptions, repository: Repository ): Promise> => { const { before, after, first, last, limit, ...findOptions } = sanitizeOptions(options); const totalCount = await repository.count(findOptions); const offsets = getOffsets({ before, after, first, last, limit, totalCount, }); const { skip, take } = getSkipAndTake(offsets); // records const entities = await repository.find({ ...findOptions, skip, take }); return getConnection({ ...offsets, entities, totalCount, last, first, after, before, }); }; export const connectionFromQuery = async ( options: Pick< ConnectionFromRepositoryOptions, 'first' | 'before' | 'after' | 'last' | 'limit' >, query: SelectQueryBuilder ): Promise> => { const { before, after, first, last, limit } = sanitizeOptions(options); const totalCount = await query.getCount(); const offsets = getOffsets({ before, after, first, last, limit, totalCount, }); const { skip, take } = getSkipAndTake(offsets); // records const entities = await query.skip(skip).take(take).getMany(); return getConnection({ ...offsets, entities, totalCount, last, first, after, before, }); }; export const connectionFromArray = async ( options: Pick< ConnectionFromRepositoryOptions, 'first' | 'before' | 'after' | 'last' | 'limit' >, array: Array ): Promise> => { const { before, after, first, last, limit } = sanitizeOptions(options); const totalCount = array.length; const offsets = getOffsets({ before, after, first, last, limit, totalCount, }); const { skip, take } = getSkipAndTake(offsets); // records const entities = array.slice(skip, skip + take); return getConnection({ ...offsets, entities, totalCount, last, first, after, before, }); }; /** * Assertions whether the `loadMany` result from a dataloader has any errors. */ export function assertNoLoadManyErrors( results: Array, message = 'Found results with errors' ): asserts results is Array { const { length } = results.filter((result) => !(result instanceof Error)); if (length) { throw new AssertionError({ message, actual: length, expected: 0 }); } }