|
| 1 | +import { |
| 2 | + Model, |
| 3 | + ModelCtor as ModelClass, |
| 4 | + Sequelize, |
| 5 | + Op, |
| 6 | + FindOptions, |
| 7 | + OrderItem as SequelizeOrderItem, |
| 8 | + Logging, |
| 9 | + Transactionable, |
| 10 | +} from 'sequelize'; |
| 11 | +import { getPrimaryColumns, matchAssociationReference } from './sequelize-utils'; |
| 12 | +import { MaybePromise } from './types'; |
| 13 | + |
| 14 | +/** |
| 15 | + * @module sequelize-find-by-cursor |
| 16 | + * |
| 17 | + * A sequelize implementation of cursor-based pagination (find after or before another entity). |
| 18 | + * |
| 19 | + * Based on |
| 20 | + * @link https://facebook.github.io/relay/graphql/connections.htm |
| 21 | + */ |
| 22 | + |
| 23 | +export type ModelFinder<E> = (query) => Promise<E[]>; |
| 24 | + |
| 25 | +export type OrderTuple = [string, 'ASC' | 'DESC']; |
| 26 | + |
| 27 | +type Cursor = { [key: string]: any } |
| 28 | + |
| 29 | +interface QueryMetadata<Entity extends Model> { |
| 30 | + isLast: boolean, |
| 31 | + limit: number, |
| 32 | + |
| 33 | + sortOrder: Array<OrderTuple>, |
| 34 | + |
| 35 | + after: Cursor | null, |
| 36 | + before: Cursor | null, |
| 37 | + |
| 38 | + findAll: ModelFinder<Entity>, |
| 39 | + |
| 40 | + passDown: IDownPassed, |
| 41 | +} |
| 42 | + |
| 43 | +// TODO: this library could generate a stateless cursor |
| 44 | +// TODO: add support for extra properties: |
| 45 | +// - where: join it to our cursor where using Sequelize.and(config.where, orderQuery) |
| 46 | + |
| 47 | +interface IDownPassed extends Logging, Transactionable {} |
| 48 | + |
| 49 | +export interface FindByCursorConfig<E extends Model> extends IDownPassed { |
| 50 | + model: ModelClass<E>, |
| 51 | + order: OrderTuple[], |
| 52 | + |
| 53 | + /** |
| 54 | + * This is a cursor. If provided, only entities that are located before this cursor will be returned. |
| 55 | + * |
| 56 | + * The cursor is an object that must contain one value for each column used in the `order` property, plus the primary keys of the entity. |
| 57 | + */ |
| 58 | + before?: { [key: string]: any } | null, |
| 59 | + |
| 60 | + /** |
| 61 | + * This is a cursor. If provided, only entities that are located after this cursor will be returned. |
| 62 | + * |
| 63 | + * The cursor is an object that must contain one value for each column used in the `order` property, plus the primary keys of the entity. |
| 64 | + */ |
| 65 | + after?: { [key: string]: any } | null, |
| 66 | + first?: number | null, |
| 67 | + last?: number | null, |
| 68 | + |
| 69 | + findAll?: ModelFinder<E>, |
| 70 | +} |
| 71 | + |
| 72 | +export interface FindByCursorResult<T> { |
| 73 | + nodes: T[], |
| 74 | + hasNextPage: () => MaybePromise<boolean>, |
| 75 | + hasPreviousPage: () => MaybePromise<boolean>, |
| 76 | +} |
| 77 | + |
| 78 | +export async function sequelizeFindByCursor<Entity extends Model>( |
| 79 | + config: FindByCursorConfig<Entity>, |
| 80 | +): Promise<FindByCursorResult<Entity>> { |
| 81 | + |
| 82 | + const { |
| 83 | + model, order, after, before, first, last, |
| 84 | + findAll = (query => config.model.findAll(query)), |
| 85 | + ...passDown |
| 86 | + } = config; |
| 87 | + |
| 88 | + if (!order || order.length === 0) { |
| 89 | + throw new Error(`'order' must be specified`); |
| 90 | + } |
| 91 | + |
| 92 | + if (after && before) { |
| 93 | + // TODO |
| 94 | + throw new Error(`Having both 'before' and 'after' is not currently supported.`); |
| 95 | + } |
| 96 | + |
| 97 | + if (first != null && last != null) { |
| 98 | + throw new Error(`Having both 'first' and 'last' is not currently supported.`); |
| 99 | + } |
| 100 | + |
| 101 | + const primaryKeys: string[] = getPrimaryColumns(model) |
| 102 | + // sort by db name to ensure they are in the same order between restarts |
| 103 | + .sort((c1, c2) => c1.field.localeCompare(c2.field)) |
| 104 | + .map(col => col.fieldName); |
| 105 | + |
| 106 | + const limit = first || last; |
| 107 | + if (!Number.isSafeInteger(limit)) { |
| 108 | + throw new Error(`'first' and 'last' must be safe integers, and one of them must be provided.`); |
| 109 | + } |
| 110 | + |
| 111 | + if (limit < 0) { |
| 112 | + throw new Error(`'first' and 'last' cannot be < 0`); |
| 113 | + } |
| 114 | + |
| 115 | + // sort by PK last to ensure the [where PK] (see below) works on a consistent dataset. |
| 116 | + const pkOrderBy: OrderTuple[] = primaryKeys.map(pk => [pk, 'ASC']); |
| 117 | + const sortOrder: OrderTuple[] = [...order, ...pkOrderBy]; |
| 118 | + |
| 119 | + const queryMetadata: QueryMetadata<Entity> = { |
| 120 | + isLast: last != null, |
| 121 | + limit, |
| 122 | + sortOrder, |
| 123 | + after, |
| 124 | + before, |
| 125 | + findAll, |
| 126 | + passDown, |
| 127 | + }; |
| 128 | + |
| 129 | + const { nodes, hasMoreNodes } = await getPage<Entity>(queryMetadata); |
| 130 | + |
| 131 | + return { |
| 132 | + nodes, |
| 133 | + hasNextPage: () => hasNextPage(queryMetadata, hasMoreNodes), |
| 134 | + hasPreviousPage: () => hasPreviousPage(queryMetadata, hasMoreNodes), |
| 135 | + }; |
| 136 | +} |
| 137 | + |
| 138 | +/* |
| 139 | + hasPreviousPage is used to indicate whether more edges exist prior to the set defined by the clients arguments. |
| 140 | +
|
| 141 | + 1. If last is set: |
| 142 | + a. Let edges be the result of calling ApplyCursorsToEdges(allEdges, before, after). |
| 143 | + b. If edges contains more than last elements return true, otherwise false. |
| 144 | + 2. If after is set: |
| 145 | + a. If the server can efficiently determine that elements exist prior to after, return true. |
| 146 | + 3. Return false. |
| 147 | +*/ |
| 148 | +function hasPreviousPage(queryMetadata, hasMoreNodes) { |
| 149 | + |
| 150 | + if (queryMetadata.isLast) { |
| 151 | + return hasMoreNodes; |
| 152 | + } |
| 153 | + |
| 154 | + if (queryMetadata.after) { |
| 155 | + return getPage({ |
| 156 | + ...queryMetadata, |
| 157 | + |
| 158 | + before: queryMetadata.after, |
| 159 | + isLast: true, |
| 160 | + |
| 161 | + sortOrder: queryMetadata.sortOrder, |
| 162 | + after: null, |
| 163 | + |
| 164 | + // we take 0 items because getPage will by default take 1 more |
| 165 | + // for hasMoreNodes |
| 166 | + limit: 0, |
| 167 | + }).then(results => results.hasMoreNodes); |
| 168 | + } |
| 169 | + |
| 170 | + return false; |
| 171 | +} |
| 172 | + |
| 173 | +/* |
| 174 | + hasNextPage is used to indicate whether more edges exist following the set defined by the clients arguments. |
| 175 | +
|
| 176 | + 1. If first is set: |
| 177 | + a. Let edges be the result of calling ApplyCursorsToEdges(allEdges, before, after). |
| 178 | + b. If edges contains more than first elements return true, otherwise false. |
| 179 | + 2. If before is set: |
| 180 | + a. If the server can efficiently determine that elements exist following before, return true. |
| 181 | + 3. Return false. |
| 182 | +*/ |
| 183 | +function hasNextPage(queryMetadata, hasMoreNodes) { |
| 184 | + |
| 185 | + if (!queryMetadata.isLast) { |
| 186 | + return hasMoreNodes; |
| 187 | + } |
| 188 | + |
| 189 | + if (queryMetadata.before) { |
| 190 | + return getPage({ |
| 191 | + ...queryMetadata, |
| 192 | + after: queryMetadata.before, |
| 193 | + isLast: false, |
| 194 | + |
| 195 | + sortOrder: queryMetadata.sortOrder, |
| 196 | + before: null, |
| 197 | + |
| 198 | + // we take 0 items because getPage will by default take 1 more |
| 199 | + // for hasMoreNodes |
| 200 | + limit: 0, |
| 201 | + }).then(results => { |
| 202 | + return results.hasMoreNodes; |
| 203 | + }); |
| 204 | + } |
| 205 | + |
| 206 | + return false; |
| 207 | +} |
| 208 | + |
| 209 | +function reverseOrder(order) { |
| 210 | + if (!order) { |
| 211 | + return order; |
| 212 | + } |
| 213 | + |
| 214 | + return order.map(orderPart => { |
| 215 | + const direction = orderPart[1] === 'ASC' ? 'DESC' : 'ASC'; |
| 216 | + |
| 217 | + return [orderPart[0], direction]; |
| 218 | + }); |
| 219 | +} |
| 220 | + |
| 221 | +enum CursorType { |
| 222 | + AFTER, |
| 223 | + BEFORE, |
| 224 | +} |
| 225 | + |
| 226 | +async function getPage<Entity extends Model>( |
| 227 | + queryMetadata: QueryMetadata<Entity>, |
| 228 | +): Promise<{ nodes: Entity[], hasMoreNodes: boolean }> { |
| 229 | + |
| 230 | + const { sortOrder, after, before, isLast, findAll, passDown } = queryMetadata; |
| 231 | + |
| 232 | + const queryOrder = orderTupleToSequelizeOrder(isLast ? reverseOrder(sortOrder) : sortOrder); |
| 233 | + const query: FindOptions = { |
| 234 | + ...passDown, // Transactionable & Logging |
| 235 | + limit: queryMetadata.limit, |
| 236 | + order: queryOrder, |
| 237 | + |
| 238 | + // subqueries are not compatible with referencing a joined table in `order` |
| 239 | + // TODO: This should be fixed in Sequelize, need a bug report |
| 240 | + subQuery: queryOrder.find(item => item.length === 3) == null, |
| 241 | + }; |
| 242 | + |
| 243 | + /* |
| 244 | + * The basic idea to implement an `after: x` in SQL is to ORDER BY the results by a set of fields |
| 245 | + * |
| 246 | + * Then filter out the rows that are before `after` by filtering on each item of the ORDER BY clause |
| 247 | + * |
| 248 | + * e.g. If order by pk ASC: |
| 249 | + * |
| 250 | + * WHERE pk > after.pk |
| 251 | + * |
| 252 | + * e.g. If order by firstName ASC, pk ASC: |
| 253 | + * |
| 254 | + * WHERE firstName > after.firstName OR (firstName = after.firstName AND pk > after.pk) |
| 255 | + * |
| 256 | + * e.g. If ordering by firstName ASC, lastName ASC, pk ASC: |
| 257 | + * |
| 258 | + * WHERE firstName > after.firstName |
| 259 | + * OR (firstName = after.firstName AND (lastName > after.lastName |
| 260 | + * OR (lastName = after.lastName AND pk > after.pk))) |
| 261 | + */ |
| 262 | + |
| 263 | + const wheres = []; |
| 264 | + |
| 265 | + if (after != null) { |
| 266 | + wheres.push(buildOrderQuery(sortOrder, after, CursorType.AFTER)); |
| 267 | + } |
| 268 | + |
| 269 | + if (before != null) { |
| 270 | + wheres.push(buildOrderQuery(sortOrder, before, CursorType.BEFORE)); |
| 271 | + } |
| 272 | + |
| 273 | + if (wheres.length > 0) { |
| 274 | + query.where = wheres.length === 1 ? wheres[0] : Sequelize.and(...wheres); |
| 275 | + } |
| 276 | + |
| 277 | + // get one more result than needed to check if there are still results after this |
| 278 | + query.limit += 1; |
| 279 | + |
| 280 | + const currentPageResults: Entity[] = await findAll(query); |
| 281 | + |
| 282 | + if (queryMetadata.isLast) { |
| 283 | + currentPageResults.reverse(); |
| 284 | + } |
| 285 | + |
| 286 | + const hasMoreResults = currentPageResults.length === queryMetadata.limit + 1; |
| 287 | + if (hasMoreResults) { |
| 288 | + if (queryMetadata.isLast) { |
| 289 | + currentPageResults.shift(); |
| 290 | + } else { |
| 291 | + currentPageResults.pop(); |
| 292 | + } |
| 293 | + } |
| 294 | + |
| 295 | + return { nodes: currentPageResults, hasMoreNodes: hasMoreResults }; |
| 296 | +} |
| 297 | + |
| 298 | +function buildOrderQuery(orderBy: OrderTuple[], cursor: Cursor, cursorType: CursorType) { |
| 299 | + const operators = cursorType === CursorType.AFTER ? { |
| 300 | + ASC: Op.gt, |
| 301 | + DESC: Op.lt, |
| 302 | + } : { |
| 303 | + ASC: Op.lt, |
| 304 | + DESC: Op.gt, |
| 305 | + }; |
| 306 | + |
| 307 | + let orderQuery; |
| 308 | + |
| 309 | + // we build the sort order from the inside out (starting with the last item, to the first) |
| 310 | + { |
| 311 | + // very last item: orderQuery = pk > after.pk |
| 312 | + const lastSortEntry = orderBy[orderBy.length - 1]; |
| 313 | + const [sortColumn, orderDirection] = lastSortEntry; |
| 314 | + |
| 315 | + const operator = operators[orderDirection]; |
| 316 | + |
| 317 | + if (!(sortColumn in cursor)) { |
| 318 | + throw new Error(`cursor is missing key ${sortColumn}`); |
| 319 | + } |
| 320 | + |
| 321 | + orderQuery = { |
| 322 | + [sortColumn]: { [operator]: cursor[sortColumn] }, |
| 323 | + }; |
| 324 | + } |
| 325 | + |
| 326 | + // subsequent items: |
| 327 | + // orderQuery = lastName > after.lastName OR (lastName = after.lastName AND {orderQuery}) |
| 328 | + for (let i = orderBy.length - 2; i >= 0; i--) { |
| 329 | + const [sortColumn, orderDirection]: [string, string] = orderBy[i]; |
| 330 | + const operator = operators[orderDirection]; |
| 331 | + |
| 332 | + if (!(sortColumn in cursor)) { |
| 333 | + throw new Error(`cursor is missing key ${sortColumn}`); |
| 334 | + } |
| 335 | + |
| 336 | + // orderQuery |
| 337 | + orderQuery = Sequelize.or( |
| 338 | + { [sortColumn]: { [operator]: cursor[sortColumn] } }, |
| 339 | + // @ts-ignore |
| 340 | + Sequelize.and( |
| 341 | + { [sortColumn]: cursor[sortColumn] }, |
| 342 | + orderQuery, |
| 343 | + ), |
| 344 | + ); |
| 345 | + } |
| 346 | + |
| 347 | + return orderQuery; |
| 348 | +} |
| 349 | + |
| 350 | +// TODO: PR sequelize to support $association.column$ in `order` as they already support it in `where` |
| 351 | +export function orderTupleToSequelizeOrder(orders: OrderTuple[]): SequelizeOrderItem[] { |
| 352 | + return orders.map(order => { |
| 353 | + const [column, direction] = order; |
| 354 | + |
| 355 | + const associationReference = matchAssociationReference(column); |
| 356 | + if (!associationReference) { |
| 357 | + return order; |
| 358 | + } |
| 359 | + |
| 360 | + return [ |
| 361 | + /* association name */ associationReference[0], |
| 362 | + /* association column */ associationReference[1], |
| 363 | + direction, |
| 364 | + ]; |
| 365 | + }); |
| 366 | +} |
0 commit comments