Skip to content

Instantly share code, notes, and snippets.

@danielrearden
Created October 26, 2020 15:25
Show Gist options
  • Save danielrearden/61a4e5b6382f17b3560ae716d36d8278 to your computer and use it in GitHub Desktop.
Save danielrearden/61a4e5b6382f17b3560ae716d36d8278 to your computer and use it in GitHub Desktop.
createJunctionConnectionLoaderClass
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