Created
October 26, 2020 15:25
-
-
Save danielrearden/61a4e5b6382f17b3560ae716d36d8278 to your computer and use it in GitHub Desktop.
createJunctionConnectionLoaderClass
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import DataLoader from 'dataloader'; | |
import _ from 'lodash'; | |
import { | |
sql, | |
DatabaseConnectionType, | |
IdentifierSqlTokenType, | |
PrimitiveValueExpressionType, | |
SqlSqlTokenType, | |
SqlTokenType, | |
} from 'slonik'; | |
import { | |
DatabaseRecordIdType, | |
OrderDirection, | |
RelayConnection, | |
} from '../types'; | |
import { fromCursor, toCursor } from '../utilities'; | |
type CreateLoaderClassOptions<T> = { | |
nodeTable: { | |
alias?: string; | |
name: string; | |
}; | |
queryFactory: (expressions: { | |
limit: number | null; | |
orderBy: SqlTokenType; | |
select: SqlTokenType; | |
where: SqlTokenType; | |
}) => SqlSqlTokenType<T>; | |
}; | |
type QueryFactoryIdentifiers<T> = { | |
[P in keyof T]: IdentifierSqlTokenType; | |
}; | |
type AllQueryFactoryIdentifiers<T extends { id: DatabaseRecordIdType }> = { | |
edge: {}; | |
node: QueryFactoryIdentifiers<T>; | |
}; | |
type DataLoaderKey<T extends { id: DatabaseRecordIdType }> = { | |
after?: string; | |
first?: number; | |
orderBy?: ( | |
identifiers: AllQueryFactoryIdentifiers<T> | |
) => [SqlTokenType, OrderDirection][]; | |
where?: ( | |
identifiers: AllQueryFactoryIdentifiers<T> | |
) => SqlSqlTokenType<unknown>; | |
}; | |
const getFieldIdentifiers = <T>(tableAlias: string) => { | |
// eslint-disable-next-line fp/no-proxy | |
return new Proxy( | |
{}, | |
{ | |
get: (_target, property: string) => | |
sql.identifier([tableAlias, _.snakeCase(property)]), | |
} | |
) as QueryFactoryIdentifiers<T>; | |
}; | |
export const createConnectionLoaderClass = < | |
T extends { id: DatabaseRecordIdType } | |
>( | |
options: CreateLoaderClassOptions<T> | |
) => { | |
const { nodeTable, queryFactory } = options; | |
const nodeTableName = nodeTable.name; | |
const nodeTableAlias = nodeTable.alias || nodeTableName; | |
const allIdentifiers = { | |
edge: {}, | |
node: getFieldIdentifiers<T>(nodeTableAlias), | |
}; | |
return class ConnectionDataLoaderClass extends DataLoader< | |
DataLoaderKey<T>, | |
RelayConnection<T>, | |
string | |
> { | |
constructor( | |
connection: DatabaseConnectionType, | |
loaderOptions?: DataLoader.Options< | |
DataLoaderKey<T>, | |
RelayConnection<T>, | |
string | |
> | |
) { | |
super( | |
async (loaderKeys) => { | |
const queries = loaderKeys.map((loaderKey, index) => { | |
const { where, orderBy, first, after } = loaderKey; | |
const conditions: SqlTokenType[] = where | |
? [where(allIdentifiers)] | |
: []; | |
const queryKey = String(index); | |
const selectExpressions = [sql`${queryKey} "loaderQueryKey"`]; | |
const idColumn = sql.identifier([nodeTableAlias, 'id']); | |
const orderByExpressions: [SqlTokenType, OrderDirection][] = orderBy | |
? orderBy(allIdentifiers) | |
: []; | |
// If we're not already sorting by the primary key, add it as an expression. | |
// This ensures consistent ordering and accurate pagination even if we sort by non-unique columns. | |
if ( | |
!orderByExpressions.find( | |
([expression]) => | |
sql`${expression}`.sql === sql`${idColumn}`.sql | |
) | |
) { | |
orderByExpressions.push([idColumn, 'ASC']); | |
} | |
orderByExpressions.forEach(([expression], idx) => { | |
selectExpressions.push( | |
sql`${expression} ${sql.identifier(['orderBy' + idx])}` | |
); | |
}); | |
const orderByClause = sql.join( | |
orderByExpressions.map( | |
([, direction], idx) => | |
sql`${sql.identifier(['orderBy' + idx])} ${ | |
direction === 'ASC' ? sql`ASC` : sql`DESC` | |
}` | |
), | |
sql`,` | |
); | |
if (after) { | |
const values = fromCursor(after); | |
conditions.push( | |
sql.join( | |
orderByExpressions.map((_orderByExpression, outerIndex) => { | |
const expressions = orderByExpressions.slice( | |
0, | |
outerIndex + 1 | |
); | |
return sql`(${sql.join( | |
expressions.map(([expression, direction], innerIndex) => { | |
let comparisonOperator = sql`=`; | |
if (innerIndex === expressions.length - 1) { | |
comparisonOperator = | |
direction === 'ASC' ? sql`>` : sql`<`; | |
} | |
return sql`${expression} ${comparisonOperator} ${values[innerIndex]}`; | |
}), | |
sql` AND ` | |
)})`; | |
}), | |
sql` OR ` | |
) | |
); | |
} | |
const whereExpression = conditions.length | |
? sql`${sql.join(conditions, sql` AND `)}` | |
: sql`true`; | |
const limit = first ? first + 1 : null; | |
return sql`(${queryFactory({ | |
limit, | |
orderBy: orderByClause, | |
select: sql.join(selectExpressions, sql`, `), | |
where: whereExpression, | |
})})`; | |
}); | |
const query = sql`${sql.join(queries, sql`UNION ALL`)}`; | |
const records = await connection.any< | |
T & Record<string, PrimitiveValueExpressionType> | |
>(query); | |
const connections = loaderKeys.map((loaderKey, index) => { | |
const queryKey = String(index); | |
const { first, after } = loaderKey; | |
const edges = records | |
.filter((record) => { | |
return record.loaderQueryKey === queryKey; | |
}) | |
.map((record) => { | |
// eslint-disable-next-line @typescript-eslint/no-unused-vars | |
const { loaderQueryKey, ...rest } = record; | |
const node = (rest as unknown) as T; | |
const cursorValues = new Array(20) | |
.fill('') | |
.map((_item, idx) => record['orderBy' + idx]) | |
.filter((value) => value); | |
return { | |
cursor: toCursor(cursorValues), | |
node, | |
}; | |
}); | |
const slicedEdges = edges.slice(0, first); | |
const pageInfo = { | |
endCursor: slicedEdges.length | |
? slicedEdges[slicedEdges.length - 1].cursor | |
: null, | |
hasNextPage: Boolean(edges.length > slicedEdges.length), | |
hasPreviousPage: Boolean(after), | |
startCursor: slicedEdges.length ? slicedEdges[0].cursor : null, | |
}; | |
return { | |
edges: slicedEdges, | |
pageInfo, | |
}; | |
}); | |
return connections; | |
}, | |
{ | |
...loaderOptions, | |
cacheKeyFn: (key) => { | |
const after = key.after || null; | |
const first = key.first || null; | |
let orderBy: string | null = null; | |
let where: string | null = null; | |
if (key.orderBy) { | |
const expressions = key.orderBy(allIdentifiers); | |
orderBy = expressions | |
.map(([column, direction]) => { | |
const token = sql`${column}`; | |
return `${token.sql}:${token.values.join(',')}:${direction}`; | |
}) | |
.join(','); | |
} | |
if (key.where) { | |
const expression = key.where(allIdentifiers); | |
where = `${expression.sql}:${expression.values.join(',')}`; | |
} | |
const normalizedKey: { | |
[P in keyof DataLoaderKey<T>]-?: string | number | null; | |
} = { after, first, orderBy, where }; | |
return JSON.stringify(normalizedKey); | |
}, | |
} | |
); | |
} | |
}; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment