├── .env ├── .github └── workflows │ ├── main.yml │ └── size.yml ├── .gitignore ├── LICENSE ├── README.md ├── examples ├── README.md └── paginate.ts ├── package.json ├── src └── index.ts ├── test ├── README.md ├── config.ts ├── index.test.ts └── populateTable.ts ├── tsconfig.json └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | HASH_KEY_NAME=hashKey 2 | HASH_KEY_VALUE=hashKeyValue 3 | RANGE_KEY_NAME=rangeKey 4 | TABLE_NAME=DynamoDBCursorBasedPagination 5 | REGION=us-east-1 6 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['10.x', '12.x', '14.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | # - name: Test 29 | # run: yarn test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | coverage 6 | scripts 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 TTOSS - Pedro Arantes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DynamoDB Cursor-Based Pagination 2 | 3 | ## Introduction 4 | 5 | ### DynamoDB 6 | 7 | From Amazon DynamoDB [page](https://aws.amazon.com/dynamodb): 8 | 9 | > Amazon DynamoDB is a key-value and document database that delivers single-digit millisecond performance at any scale. It's a fully managed, multiregion, multimaster, durable database with built-in security, backup and restore, and in-memory caching for internet-scale applications. DynamoDB can handle more than 10 trillion requests per day and can support peaks of more than 20 million requests per second. 10 | 11 | To achieve this hyper performance and scalability , it lacks common RDBMS and some NoSQL databases features. For that reason, DynamoDB provides only features that are scalable and its query is one of them. 12 | 13 | [Querying on DynamoDB](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Query.html) is not powerful compared to other databases. It must satisfy some requirements: 14 | 15 | 1. The table or the secondary index must have a [composite primary key](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html#HowItWorks.CoreComponents.PrimaryKey) (a partition/hash key and a sort/range key). 16 | 2. The partition key must be defined and the sort key is not required. If defined, it's used to [sort the items](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.html#Query.KeyConditionExpressions). 17 | 18 | For instance, you cannot query items whose hash key is different. Because of that you have to [design your table and indexes](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/best-practices.html) to support all queries you need to perform. 19 | 20 | ### Cursor-Based Pagination 21 | 22 | If you're familiarized with GraphQL, you may have seen from [its documentation](https://graphql.org/learn/pagination/) that cursor-based pagination is more powerful than others pagination designs. Also, if we do some search, we can find comparisons among pagination designs ([here](https://medium.com/swlh/how-to-implement-cursor-pagination-like-a-pro-513140b65f32)) and cursor-based pagination is the winner. 23 | 24 | Considering the advantages of cursor-based pagination, this package proposes a design to allow us perform cursor-based pagination in a DBB table. 25 | 26 | ## Table Design 27 | 28 | There aren't much requirements to achieve to be able to perform cursor-based pagination. In resume, we need: 29 | 30 | 1. A table or a secondary index with a [composite primary key](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html#HowItWorks.CoreComponents.PrimaryKey). 31 | 1. Items must be saved in such way that the range key ordination must represent the ordination of the pagination. 32 | 33 | Why do we need the second requirement? First we need to understand what cursor is. Cursor is like an edge identifier, with cursor we must be able to retrieve and locate that edge on your backend. In a DBB table with composite primary key, the sort key is a good choice to be our cursor. 34 | 35 | ## Installation 36 | 37 | ``` 38 | npm install -S dynamodb-cursor-based-pagination 39 | ``` 40 | 41 | or 42 | 43 | ``` 44 | yarn add dynamodb-cursor-based-pagination 45 | ``` 46 | 47 | You also need install `aws-sdk` in your project because it is a peer dependency of this project. 48 | 49 | ## How to Use 50 | 51 | ``` 52 | import { paginate } from 'dynamodb-cursor-based-pagination'; 53 | ``` 54 | 55 | `paginate` is a method whose signature is: 56 | 57 | ```ts 58 | type paginate = ({ 59 | credentials, 60 | region, 61 | tableName, 62 | hashKeyName, 63 | hashKeyValue, 64 | rangeKeyName, 65 | indexName, 66 | projectionExpression, 67 | filterExpression, 68 | filterAttributeNames, 69 | filterAttributeValues, 70 | beginsWith, 71 | sort, 72 | after, 73 | first, 74 | before, 75 | last, 76 | }: { 77 | credentials?: Credentials | undefined; 78 | region: string; 79 | tableName: string; 80 | hashKeyName: string; 81 | hashKeyValue: string; 82 | rangeKeyName: string; 83 | beginsWith?: string | undefined; 84 | indexName?: string | undefined; 85 | projectionExpression?: string | undefined; 86 | filterExpression?: string | undefined; 87 | filterAttributeNames?: 88 | | { 89 | [key: string]: string; 90 | } 91 | | undefined; 92 | filterAttributeValues?: 93 | | { 94 | [key: string]: any; 95 | } 96 | | undefined; 97 | sort?: 'ASC' | 'DESC' | undefined; 98 | after?: string | undefined; 99 | before?: string | undefined; 100 | first?: number | undefined; 101 | last?: number | undefined; 102 | }) => Promise<{ 103 | edges: { 104 | cursor: string; 105 | node: T; 106 | }[]; 107 | pageInfo: { 108 | hasPreviousPage: boolean; 109 | hasNextPage: boolean; 110 | startCursor?: string | undefined; 111 | endCursor?: string | undefined; 112 | }; 113 | consumedCapacity: number | undefined; 114 | count: number | undefined; 115 | scannedCount: number | undefined; 116 | lastEvaluatedKey: string | undefined; 117 | }>; 118 | ``` 119 | 120 | ### Credentials 121 | 122 | You must have AWS credentials in your environment to use this package. The only permission needed is [dynamodb:Query](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/api-permissions-reference.html). 123 | 124 | If you don't have credentials in your environment, you may want provide them passing a [Credentials object](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Credentials.html) to `credentials`: 125 | 126 | ```ts 127 | import { Credentials } from 'aws-sdk'; 128 | import { paginate } from 'dynamodb-cursor-based-pagination'; 129 | 130 | const credentials = new Credentials({ 131 | accessKeyId: ..., 132 | secretAccessKey: ..., 133 | sessionToken: ... 134 | }) 135 | 136 | paginate({ 137 | credentials, 138 | ... 139 | }) 140 | ... 141 | ``` 142 | 143 | ### DynamoDB Table Parameters 144 | 145 | The parameters `region`, `tableName`, `hashKeyName`, `hashKeyValue`, `rangeKeyName`, `indexName` are used to identify your DynamoDB table and the partition. 146 | 147 | ### Cursor Parameters 148 | 149 | - `first` and `after`: [forward pagination arguments.](https://relay.dev/graphql/connections.htm#sec-Forward-pagination-arguments) 150 | - `last` and `before`: [backward pagination arguments.](https://relay.dev/graphql/connections.htm#sec-Backward-pagination-arguments) 151 | 152 | ### Sorting 153 | 154 | - `sort: 'ASC' | 'DESC' (default 'DESC')` 155 | 156 | Querying on DynamoBD is related to the sorting of the items in function of their sort key value. Because of this, the parameter `sort` defines the items order before perform pagination. `ASC` is for ascending sorting (`a`, `b`, ..., `z`) and `DESC`, for descending (`z`, `y`, ..., `a`). 157 | 158 | ### Begins With 159 | 160 | - `beginsWith: string | undefined` 161 | 162 | Your DynamoDB table may have an architecture that made the items have a [`beginsWith` property](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.html#Query.KeyConditionExpressions). If you want to paginate over items that have such property, just add `beginsWith` to `paginate` method. 163 | 164 | ### Projection Expression 165 | 166 | - `projectionExpression: string | undefined` 167 | 168 | [DynamoDB projection expression reference](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ProjectionExpressions.html). 169 | 170 | ### Filtering 171 | 172 | - `filterExpression?: string | undefined` 173 | - `filterAttributeNames: { [key: string]: string; } | undefined` 174 | - `filterAttributeValues: { [key: string]: string; } | undefined` 175 | 176 | [DynamoDB filtering reference](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.html#Query.FilterExpression). 177 | 178 | ```ts 179 | // Example 180 | 181 | paginate({ 182 | ... // oher params, 183 | filterExpression: '#parity = :parity', 184 | filterAttributeNames: { 185 | '#parity': 'parity', 186 | }, 187 | filterAttributeValues: { 188 | ':parity': 'EVEN', 189 | }, 190 | }); 191 | ``` 192 | 193 | ## Examples 194 | 195 | Let's check [some examples](examples/README.md) to understand better [these requirements and the design](#table-design). 196 | 197 | ## Test 198 | 199 | To test against a real table: 200 | 201 | ``` 202 | yarn run test 203 | ``` 204 | 205 | To test against a real table, provide these values in your environment - you may want provide them in a `.env` file: 206 | 207 | ``` 208 | AWS_ACCESS_KEY_ID 209 | AWS_SECRET_ACCESS_KEY 210 | AWS_SESSION_TOKEN 211 | HASH_KEY_NAME 212 | HASH_KEY_VALUE 213 | RANGE_KEY_NAME 214 | TABLE_NAME 215 | REGION 216 | INDEX_NAME 217 | BEGINS_WITH 218 | ``` 219 | 220 | Also, you need to populate the table with data to be tested. Please, refer to [this section](test/README.md#populate-dbb-table) to add data to the table. 221 | 222 | ## Author 223 | 224 | - [Pedro Arantes](https://twitter.com/arantespp) 225 | 226 | ## License 227 | 228 | [MIT](./LICENSE) 229 | 230 | --- 231 | 232 | Bootstrapped with [tsdx](https://github.com/formium/tsdx) 233 | 234 | Made with ❤️ 235 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Install Dependencies 4 | 5 | ``` 6 | yarn 7 | ``` 8 | 9 | ## Populate DBB Table 10 | 11 | ``` 12 | yarn run populate-table 13 | ``` 14 | 15 | See `test/README.md` for more details. 16 | 17 | ## Use Cases 18 | 19 | Check the tests case to see more examples. 20 | 21 | ### Examples 22 | 23 | #### Case 1 - `first: 3` 24 | 25 | - Command: 26 | 27 | ```sh 28 | yarn run paginate --first=3 29 | ``` 30 | 31 | - Query Parameters 32 | 33 | ```json 34 | { 35 | "ExpressionAttributeNames": { "#paginateHashKey": "hashKey" }, 36 | "ExpressionAttributeValues": { ":paginateHashKey": "hashKeyValue" }, 37 | "KeyConditionExpression": "#paginateHashKey = :paginateHashKey", 38 | "TableName": "DynamoDBCursorBasedPagination", 39 | "ScanIndexForward": false, 40 | "Limit": 3 41 | } 42 | ``` 43 | 44 | - Response 45 | 46 | ```json 47 | { 48 | "edges": [ 49 | { 50 | "cursor": "cursor-34", 51 | "node": { 52 | "index": 34, 53 | "hashKey": "hashKeyValue", 54 | "rangeKey": "cursor-34", 55 | "parity": "EVEN" 56 | } 57 | }, 58 | { 59 | "cursor": "cursor-33", 60 | "node": { 61 | "index": 33, 62 | "hashKey": "hashKeyValue", 63 | "rangeKey": "cursor-33", 64 | "parity": "ODD" 65 | } 66 | }, 67 | { 68 | "cursor": "cursor-32", 69 | "node": { 70 | "index": 32, 71 | "hashKey": "hashKeyValue", 72 | "rangeKey": "cursor-32", 73 | "parity": "EVEN" 74 | } 75 | } 76 | ], 77 | "pageInfo": { 78 | "hasPreviousPage": false, 79 | "hasNextPage": true, 80 | "startCursor": "cursor-34", 81 | "endCursor": "cursor-32" 82 | }, 83 | "count": 3, 84 | "scannedCount": 3, 85 | "lastEvaluatedKey": "cursor-32" 86 | } 87 | ``` 88 | 89 | #### Case 2 - `first: 2` and `after: cursor-30` 90 | 91 | - Command: 92 | 93 | ```sh 94 | yarn run paginate --first=2 --after=cursor-30 95 | ``` 96 | 97 | - Query Parameters 98 | 99 | ```json 100 | { 101 | "ExpressionAttributeNames": { 102 | "#paginateHashKey": "hashKey", 103 | "#paginateCursor": "rangeKey" 104 | }, 105 | "ExpressionAttributeValues": { 106 | ":paginateHashKey": "hashKeyValue", 107 | ":paginateCursor": "cursor-30" 108 | }, 109 | "KeyConditionExpression": "#paginateHashKey = :paginateHashKey AND #paginateCursor < :paginateCursor", 110 | "TableName": "DynamoDBCursorBasedPagination", 111 | "ScanIndexForward": false, 112 | "Limit": 2 113 | } 114 | ``` 115 | 116 | - Response 117 | 118 | ```json 119 | { 120 | "edges": [ 121 | { 122 | "cursor": "cursor-29", 123 | "node": { 124 | "index": 29, 125 | "hashKey": "hashKeyValue", 126 | "rangeKey": "cursor-29", 127 | "parity": "ODD" 128 | } 129 | }, 130 | { 131 | "cursor": "cursor-28", 132 | "node": { 133 | "index": 28, 134 | "hashKey": "hashKeyValue", 135 | "rangeKey": "cursor-28", 136 | "parity": "EVEN" 137 | } 138 | } 139 | ], 140 | "pageInfo": { 141 | "hasPreviousPage": true, 142 | "hasNextPage": true, 143 | "startCursor": "cursor-29", 144 | "endCursor": "cursor-28" 145 | }, 146 | "count": 2, 147 | "scannedCount": 2, 148 | "lastEvaluatedKey": "cursor-28" 149 | } 150 | ``` 151 | 152 | #### Case 3 - `last: 3` and `before: cursor-15` 153 | 154 | - Command 155 | 156 | ```sh 157 | yarn run paginate --last=3 --before=cursor-15 158 | ``` 159 | 160 | - Query Parameters 161 | 162 | ```json 163 | { 164 | "ExpressionAttributeNames": { 165 | "#paginateHashKey": "hashKey", 166 | "#paginateCursor": "rangeKey" 167 | }, 168 | "ExpressionAttributeValues": { 169 | ":paginateHashKey": "hashKeyValue", 170 | ":paginateCursor": "cursor-15" 171 | }, 172 | "KeyConditionExpression": "#paginateHashKey = :paginateHashKey AND #paginateCursor > :paginateCursor", 173 | "TableName": "DynamoDBCursorBasedPagination", 174 | "ScanIndexForward": true, 175 | "Limit": 3 176 | } 177 | ``` 178 | 179 | - Response 180 | 181 | ```json 182 | { 183 | "edges": [ 184 | { 185 | "cursor": "cursor-18", 186 | "node": { 187 | "index": 18, 188 | "hashKey": "hashKeyValue", 189 | "rangeKey": "cursor-18", 190 | "parity": "EVEN" 191 | } 192 | }, 193 | { 194 | "cursor": "cursor-17", 195 | "node": { 196 | "index": 17, 197 | "hashKey": "hashKeyValue", 198 | "rangeKey": "cursor-17", 199 | "parity": "ODD" 200 | } 201 | }, 202 | { 203 | "cursor": "cursor-16", 204 | "node": { 205 | "index": 16, 206 | "hashKey": "hashKeyValue", 207 | "rangeKey": "cursor-16", 208 | "parity": "EVEN" 209 | } 210 | } 211 | ], 212 | "pageInfo": { 213 | "hasPreviousPage": true, 214 | "hasNextPage": true, 215 | "startCursor": "cursor-18", 216 | "endCursor": "cursor-16" 217 | }, 218 | "count": 3, 219 | "scannedCount": 3, 220 | "lastEvaluatedKey": "cursor-18" 221 | } 222 | ``` 223 | 224 | #### Case 4 - `last: 4`, `before: 5` AND `beginsWith: cursor-1` 225 | 226 | - Command 227 | 228 | ```sh 229 | yarn run paginate --last=7 --before=5 --beginsWith=cursor-1 230 | ``` 231 | 232 | - Query Parameters 233 | 234 | ```json 235 | { 236 | "ExpressionAttributeNames": { 237 | "#paginateHashKey": "hashKey", 238 | "#paginateCursor": "rangeKey" 239 | }, 240 | "ExpressionAttributeValues": { 241 | ":paginateHashKey": "hashKeyValue", 242 | ":paginateCursor": "cursor-15" 243 | }, 244 | "KeyConditionExpression": "#paginateHashKey = :paginateHashKey AND #paginateCursor > :paginateCursor", 245 | "TableName": "DynamoDBCursorBasedPagination", 246 | "ScanIndexForward": true, 247 | "Limit": 7 248 | } 249 | ``` 250 | 251 | - Response 252 | 253 | ```json 254 | { 255 | "edges": [ 256 | { 257 | "cursor": "9", 258 | "node": { 259 | "index": 19, 260 | "hashKey": "hashKeyValue", 261 | "rangeKey": "cursor-19", 262 | "parity": "ODD" 263 | } 264 | }, 265 | { 266 | "cursor": "8", 267 | "node": { 268 | "index": 18, 269 | "hashKey": "hashKeyValue", 270 | "rangeKey": "cursor-18", 271 | "parity": "EVEN" 272 | } 273 | }, 274 | { 275 | "cursor": "7", 276 | "node": { 277 | "index": 17, 278 | "hashKey": "hashKeyValue", 279 | "rangeKey": "cursor-17", 280 | "parity": "ODD" 281 | } 282 | }, 283 | { 284 | "cursor": "6", 285 | "node": { 286 | "index": 16, 287 | "hashKey": "hashKeyValue", 288 | "rangeKey": "cursor-16", 289 | "parity": "EVEN" 290 | } 291 | } 292 | ], 293 | "pageInfo": { 294 | "hasPreviousPage": false, 295 | "hasNextPage": true, 296 | "startCursor": "9", 297 | "endCursor": "6" 298 | }, 299 | "count": 7, 300 | "scannedCount": 7, 301 | "lastEvaluatedKey": "cursor-22" 302 | } 303 | ``` 304 | 305 | #### Case 5 - `first: 2`, `after: cursor-30` and `sort: ASC` 306 | 307 | - Command 308 | 309 | ```sh 310 | yarn run paginate --first=2 --after=cursor-30 --sort=ASC 311 | ``` 312 | 313 | - Query Parameters 314 | 315 | ```json 316 | { 317 | "ExpressionAttributeNames": { 318 | "#paginateHashKey": "hashKey", 319 | "#paginateCursor": "rangeKey" 320 | }, 321 | "ExpressionAttributeValues": { 322 | ":paginateHashKey": "hashKeyValue", 323 | ":paginateCursor": "cursor-30" 324 | }, 325 | "KeyConditionExpression": "#paginateHashKey = :paginateHashKey AND #paginateCursor > :paginateCursor", 326 | "TableName": "DynamoDBCursorBasedPagination", 327 | "ScanIndexForward": true, 328 | "Limit": 2 329 | } 330 | ``` 331 | 332 | - Response 333 | 334 | ```json 335 | { 336 | "edges": [ 337 | { 338 | "cursor": "cursor-31", 339 | "node": { 340 | "index": 31, 341 | "hashKey": "hashKeyValue", 342 | "rangeKey": "cursor-31", 343 | "parity": "ODD" 344 | } 345 | }, 346 | { 347 | "cursor": "cursor-32", 348 | "node": { 349 | "index": 32, 350 | "hashKey": "hashKeyValue", 351 | "rangeKey": "cursor-32", 352 | "parity": "EVEN" 353 | } 354 | } 355 | ], 356 | "pageInfo": { 357 | "hasPreviousPage": true, 358 | "hasNextPage": true, 359 | "startCursor": "cursor-31", 360 | "endCursor": "cursor-32" 361 | }, 362 | "count": 2, 363 | "scannedCount": 2, 364 | "lastEvaluatedKey": "cursor-32" 365 | } 366 | ``` 367 | 368 | #### Case 6 - Filter 369 | 370 | - Command 371 | 372 | ```sh 373 | yarn run paginate --filterExpression='#parity = :parity' --filterAttributeNames.'#parity'=parity --filterAttributeValues.':parity'=EVEN --first=6 374 | ``` 375 | 376 | - Query Parameters 377 | 378 | ```json 379 | { 380 | "ExpressionAttributeNames": { 381 | "#parity": "parity", 382 | "#paginateHashKey": "hashKey" 383 | }, 384 | "ExpressionAttributeValues": { 385 | ":parity": "EVEN", 386 | ":paginateHashKey": "hashKeyValue" 387 | }, 388 | "KeyConditionExpression": "#paginateHashKey = :paginateHashKey", 389 | "TableName": "DynamoDBCursorBasedPagination", 390 | "ScanIndexForward": false, 391 | "Limit": 6, 392 | "FilterExpression": "#parity = :parity" 393 | } 394 | ``` 395 | 396 | - Response 397 | 398 | ```json 399 | { 400 | "edges": [ 401 | { 402 | "cursor": "cursor-34", 403 | "node": { 404 | "index": 34, 405 | "hashKey": "hashKeyValue", 406 | "rangeKey": "cursor-34", 407 | "parity": "EVEN" 408 | } 409 | }, 410 | { 411 | "cursor": "cursor-32", 412 | "node": { 413 | "index": 32, 414 | "hashKey": "hashKeyValue", 415 | "rangeKey": "cursor-32", 416 | "parity": "EVEN" 417 | } 418 | }, 419 | { 420 | "cursor": "cursor-30", 421 | "node": { 422 | "index": 30, 423 | "hashKey": "hashKeyValue", 424 | "rangeKey": "cursor-30", 425 | "parity": "EVEN" 426 | } 427 | } 428 | ], 429 | "pageInfo": { 430 | "hasPreviousPage": false, 431 | "hasNextPage": true, 432 | "startCursor": "cursor-34", 433 | "endCursor": "cursor-30" 434 | }, 435 | "count": 3, 436 | "scannedCount": 6, 437 | "lastEvaluatedKey": "cursor-29" 438 | } 439 | ``` 440 | -------------------------------------------------------------------------------- /examples/paginate.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import yargs from 'yargs'; 3 | 4 | import { paginate } from '../src'; 5 | 6 | dotenv.config(); 7 | 8 | const { 9 | HASH_KEY_NAME, 10 | HASH_KEY_VALUE, 11 | RANGE_KEY_NAME, 12 | TABLE_NAME, 13 | REGION, 14 | INDEX_NAME, 15 | } = process.env; 16 | 17 | paginate({ 18 | ...yargs.argv, 19 | region: REGION, 20 | tableName: TABLE_NAME, 21 | hashKeyName: HASH_KEY_NAME, 22 | hashKeyValue: HASH_KEY_VALUE, 23 | rangeKeyName: RANGE_KEY_NAME, 24 | indexName: INDEX_NAME, 25 | }) 26 | .then(data => console.log(JSON.stringify(data, null, 2))) 27 | .catch(console.error); 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dynamodb-cursor-based-pagination", 3 | "version": "0.4.3", 4 | "license": "MIT", 5 | "main": "dist/index.js", 6 | "typings": "dist/index.d.ts", 7 | "files": [ 8 | "dist", 9 | "src" 10 | ], 11 | "engines": { 12 | "node": ">=10" 13 | }, 14 | "scripts": { 15 | "start": "tsdx watch", 16 | "build": "tsdx build", 17 | "test": "tsdx test", 18 | "lint": "tsdx lint", 19 | "prepare": "tsdx build", 20 | "size": "size-limit", 21 | "analyze": "size-limit --why", 22 | "populate-table": "npx ts-node -T -O '{\"module\": \"commonjs\"}' test/populateTable.ts", 23 | "paginate": "npx ts-node -T -O '{\"module\": \"commonjs\"}' examples/paginate.ts" 24 | }, 25 | "peerDependencies": { 26 | "aws-sdk": "^2.796.0" 27 | }, 28 | "husky": { 29 | "hooks": { 30 | "pre-commit": "tsdx lint" 31 | } 32 | }, 33 | "prettier": { 34 | "printWidth": 80, 35 | "semi": true, 36 | "singleQuote": true, 37 | "trailingComma": "es5" 38 | }, 39 | "np": { 40 | "anyBranch": false, 41 | "branch": "main" 42 | }, 43 | "author": "Pedro Arantes (https://twitter.com/arantespp)", 44 | "module": "dist/dynamodb-cursor-based-pagination.esm.js", 45 | "size-limit": [ 46 | { 47 | "path": "dist/dynamodb-cursor-based-pagination.cjs.production.min.js", 48 | "limit": "10 KB" 49 | }, 50 | { 51 | "path": "dist/dynamodb-cursor-based-pagination.esm.js", 52 | "limit": "10 KB" 53 | } 54 | ], 55 | "devDependencies": { 56 | "@size-limit/preset-small-lib": "^4.8.0", 57 | "@types/faker": "^5.1.4", 58 | "aws-sdk": "^2.797.0", 59 | "dotenv": "^8.2.0", 60 | "faker": "^5.1.0", 61 | "husky": "^4.3.0", 62 | "np": "^7.0.0", 63 | "size-limit": "^4.8.0", 64 | "ts-node": "^9.0.0", 65 | "tsdx": "^0.14.1", 66 | "tslib": "^2.0.3", 67 | "typescript": "^4.1.2", 68 | "yargs": "^16.1.1" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDB, Credentials } from 'aws-sdk'; 2 | 3 | type Sort = 'ASC' | 'DESC'; 4 | 5 | export const paginate = async ({ 6 | credentials, 7 | region, 8 | tableName, 9 | hashKeyName, 10 | hashKeyValue, 11 | rangeKeyName, 12 | indexName, 13 | projectionExpression, 14 | filterExpression, 15 | filterAttributeNames, 16 | filterAttributeValues, 17 | beginsWith = '', 18 | sort = 'DESC', 19 | after, 20 | first, 21 | before, 22 | last, 23 | }: { 24 | credentials?: Credentials; 25 | region: string; 26 | tableName: string; 27 | hashKeyName: string; 28 | hashKeyValue: string; 29 | rangeKeyName: string; 30 | beginsWith?: string; 31 | indexName?: string; 32 | projectionExpression?: string; 33 | filterExpression?: string; 34 | filterAttributeNames?: { [key: string]: string }; 35 | filterAttributeValues?: { [key: string]: any }; 36 | sort?: Sort; 37 | after?: string; 38 | before?: string; 39 | first?: number; 40 | last?: number; 41 | }) => { 42 | const documentClient = new DynamoDB.DocumentClient({ credentials, region }); 43 | 44 | const { 45 | expressionAttributeNames, 46 | expressionAttributeValues, 47 | keyConditionExpression, 48 | scanIndexForward, 49 | limit, 50 | } = (() => { 51 | const paginateHashKeyName = '#paginateHashKey'; 52 | const paginateHashKeyValue = ':paginateHashKey'; 53 | const paginateCursorName = '#paginateCursor'; 54 | const paginateCursorValue = ':paginateCursor'; 55 | 56 | const params = { 57 | expressionAttributeNames: { 58 | ...filterAttributeNames, 59 | [paginateHashKeyName]: hashKeyName, 60 | } as any, 61 | expressionAttributeValues: { 62 | ...filterAttributeValues, 63 | [paginateHashKeyValue]: hashKeyValue, 64 | } as any, 65 | keyConditionExpression: `${paginateHashKeyName} = ${paginateHashKeyValue}`, 66 | scanIndexForward: true, 67 | limit: undefined as number | undefined, 68 | }; 69 | 70 | const getKeyConditionExpression = (operator: string) => 71 | `${paginateHashKeyName} = ${paginateHashKeyValue} AND ${paginateCursorName} ${operator} ${paginateCursorValue}`; 72 | 73 | if (after || first) { 74 | if (first && first < 0) { 75 | throw new Error('FirstMustNotBeNegative'); 76 | } 77 | 78 | if (after) { 79 | params.expressionAttributeNames[paginateCursorName] = rangeKeyName; 80 | params.expressionAttributeValues[paginateCursorValue] = 81 | beginsWith + after; 82 | const operator = sort === 'ASC' ? '>' : '<'; 83 | params.keyConditionExpression = getKeyConditionExpression(operator); 84 | } 85 | 86 | params.scanIndexForward = sort === 'ASC'; 87 | params.limit = first; 88 | } else if (before || last) { 89 | if (last && last < 0) { 90 | throw new Error('LastMustNotBeNegative'); 91 | } 92 | 93 | if (before) { 94 | params.expressionAttributeNames[paginateCursorName] = rangeKeyName; 95 | params.expressionAttributeValues[paginateCursorValue] = 96 | beginsWith + before; 97 | const operator = sort === 'DESC' ? '>' : '<'; 98 | params.keyConditionExpression = getKeyConditionExpression(operator); 99 | } 100 | 101 | params.scanIndexForward = sort === 'DESC'; 102 | params.limit = last; 103 | } 104 | 105 | return params; 106 | })(); 107 | 108 | const queryDynamoDB = async () => { 109 | const queryParams: DynamoDB.DocumentClient.QueryInput = { 110 | ExpressionAttributeNames: expressionAttributeNames, 111 | ExpressionAttributeValues: expressionAttributeValues, 112 | KeyConditionExpression: keyConditionExpression, 113 | IndexName: indexName, 114 | TableName: tableName, 115 | ScanIndexForward: scanIndexForward, 116 | Limit: limit, 117 | ProjectionExpression: projectionExpression, 118 | FilterExpression: filterExpression, 119 | }; 120 | 121 | const response = await documentClient.query(queryParams).promise(); 122 | 123 | const { 124 | Items, 125 | LastEvaluatedKey, 126 | ConsumedCapacity, 127 | Count, 128 | ScannedCount, 129 | } = response; 130 | 131 | return { 132 | items: Items as T[], 133 | lastEvaluatedKey: LastEvaluatedKey?.[rangeKeyName] as string | undefined, 134 | consumedCapacity: ConsumedCapacity as number | undefined, 135 | count: Count, 136 | scannedCount: ScannedCount, 137 | }; 138 | }; 139 | 140 | const { 141 | items, 142 | lastEvaluatedKey, 143 | consumedCapacity, 144 | count, 145 | scannedCount, 146 | } = await queryDynamoDB(); 147 | 148 | /** 149 | * Used to remove beginsWith from cursor. 150 | */ 151 | const replacerRegex = new RegExp(`^${beginsWith}`); 152 | 153 | const edges = items 154 | .filter(node => String((node as any)[rangeKeyName]).startsWith(beginsWith)) 155 | .map(node => ({ 156 | cursor: String((node as any)[rangeKeyName]).replace(replacerRegex, ''), 157 | node, 158 | })) 159 | .sort( 160 | (a, b) => 161 | (sort === 'ASC' ? 1 : -1) * 162 | String(a.cursor).localeCompare(String(b.cursor)) 163 | ); 164 | 165 | const pageInfo: { 166 | hasPreviousPage: boolean; 167 | hasNextPage: boolean; 168 | startCursor?: string; 169 | endCursor?: string; 170 | } = (() => { 171 | const defaultPageInfo = { 172 | hasPreviousPage: false, 173 | hasNextPage: false, 174 | startCursor: undefined as undefined | string, 175 | endCursor: undefined as undefined | string, 176 | }; 177 | 178 | if (edges.length > 0) { 179 | defaultPageInfo.startCursor = edges[0].cursor; 180 | defaultPageInfo.endCursor = edges[edges.length - 1].cursor; 181 | } 182 | 183 | if (after) { 184 | defaultPageInfo.hasPreviousPage = true; 185 | } 186 | 187 | if (before) { 188 | defaultPageInfo.hasNextPage = true; 189 | } 190 | 191 | /** 192 | * If edges was filtered, means that more items was returned that the only 193 | * ones that starts with beginsWith, then there are next/previous page. 194 | */ 195 | const edgesWasFiltered = edges.length !== items.length; 196 | 197 | if (lastEvaluatedKey && !edgesWasFiltered && (after || first)) { 198 | defaultPageInfo.hasNextPage = true; 199 | } 200 | 201 | if (lastEvaluatedKey && !edgesWasFiltered && (before || last)) { 202 | defaultPageInfo.hasPreviousPage = true; 203 | } 204 | 205 | return defaultPageInfo; 206 | })(); 207 | 208 | return { 209 | edges, 210 | pageInfo, 211 | consumedCapacity, 212 | count, 213 | scannedCount, 214 | lastEvaluatedKey, 215 | }; 216 | }; 217 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # Tests 2 | 3 | Tests are made with a real table. 4 | 5 | ## Populate DBB Table 6 | 7 | A script was created to populate DBB table with some data to be queried in our examples. To create this package, a table with a single key, named `id`, with a composite GSI was created. The values of the region, table name, hash and range key of the GSI must be placed in a `.env` file. 8 | 9 | ```sh 10 | HASH_KEY_NAME=... // name of the GSI hash key 11 | HASH_KEY_VALUE=... // hash key value to be queried 12 | RANGE_KEY_NAME=.. // name of the range key 13 | TABLE_NAME=... // DBB table name 14 | REGION=... // DBB region 15 | INDEX_NAME=... // name of the GSI 16 | ``` 17 | 18 | With these `.env` values and valid AWS credentials in your environment, execute: 19 | 20 | ``` 21 | yarn run populate-table 22 | ``` 23 | 24 | to execute the script `populateTable.ts` to create items whose range key values goes from `cursor-10` to `cursor-34` . 25 | -------------------------------------------------------------------------------- /test/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * DO NOT CHANGE THIS VALUE ELSE TESTS WILL BREAK! 3 | */ 4 | export const NUMBER_OF_ITEMS = 25; 5 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { Credentials } from 'aws-sdk'; 2 | import faker from 'faker'; 3 | 4 | import { paginate } from '../src'; 5 | 6 | import { NUMBER_OF_ITEMS } from './config'; 7 | 8 | require('dotenv').config(); 9 | 10 | const { 11 | AWS_ACCESS_KEY_ID, 12 | AWS_SECRET_ACCESS_KEY, 13 | AWS_SESSION_TOKEN, 14 | HASH_KEY_NAME, 15 | HASH_KEY_VALUE, 16 | RANGE_KEY_NAME, 17 | TABLE_NAME, 18 | REGION, 19 | INDEX_NAME, 20 | } = process.env; 21 | 22 | const testAgainstRealTable = 23 | !!AWS_ACCESS_KEY_ID && 24 | !!AWS_SECRET_ACCESS_KEY && 25 | !!AWS_SESSION_TOKEN && 26 | !!HASH_KEY_NAME && 27 | !!HASH_KEY_VALUE && 28 | !!RANGE_KEY_NAME && 29 | !!TABLE_NAME && 30 | !!REGION; 31 | 32 | const hashKeyName = HASH_KEY_NAME || faker.random.word(); 33 | const hashKeyValue = HASH_KEY_VALUE || faker.random.word(); 34 | const rangeKeyName = RANGE_KEY_NAME || faker.random.word(); 35 | const indexName = INDEX_NAME; 36 | const tableName = TABLE_NAME || faker.random.word(); 37 | const region = REGION || 'us-east-1'; 38 | 39 | const defaultQueryParams = { 40 | credentials: testAgainstRealTable 41 | ? new Credentials({ 42 | accessKeyId: AWS_ACCESS_KEY_ID as string, 43 | secretAccessKey: AWS_SECRET_ACCESS_KEY as string, 44 | sessionToken: AWS_SESSION_TOKEN as string, 45 | }) 46 | : undefined, 47 | region, 48 | hashKeyName, 49 | hashKeyValue, 50 | rangeKeyName, 51 | indexName, 52 | tableName, 53 | }; 54 | 55 | /** 56 | * Wrap paginate and return only cursor (range key) values. 57 | */ 58 | const testPaginate = async ( 59 | params: Partial[0]> 60 | ) => { 61 | const { edges, pageInfo } = await paginate({ 62 | ...defaultQueryParams, 63 | ...params, 64 | }); 65 | return { edges: edges.map(({ cursor }) => cursor), pageInfo }; 66 | }; 67 | 68 | describe('errors', () => { 69 | it('throw error if first is negative', () => { 70 | return expect(testPaginate({ first: -1 })).rejects.toThrow(); 71 | }); 72 | 73 | it('throw error if last is negative', () => { 74 | return expect(testPaginate({ last: -1 })).rejects.toThrow(); 75 | }); 76 | }); 77 | 78 | describe('pagination', () => { 79 | test('default args', async () => { 80 | const response = await testPaginate({}); 81 | expect(response.edges.length).toEqual(NUMBER_OF_ITEMS); 82 | expect(response.pageInfo).toEqual({ 83 | hasPreviousPage: false, 84 | hasNextPage: false, 85 | startCursor: 'cursor-34', 86 | endCursor: 'cursor-10', 87 | }); 88 | }); 89 | 90 | describe('ascending sorting', () => { 91 | const sort = 'ASC' as const; 92 | 93 | describe('forward pagination', () => { 94 | it('return no items because after-cursor is after last item', () => { 95 | return expect( 96 | testPaginate({ after: 'cursor-35', sort }) 97 | ).resolves.toEqual({ 98 | edges: [], 99 | pageInfo: { 100 | hasNextPage: false, 101 | hasPreviousPage: true, 102 | }, 103 | }); 104 | }); 105 | 106 | it('return first 3 items (after-cursor undefined)', () => { 107 | return expect( 108 | testPaginate({ 109 | sort, 110 | first: 3, 111 | }) 112 | ).resolves.toEqual({ 113 | edges: ['cursor-10', 'cursor-11', 'cursor-12'], 114 | pageInfo: { 115 | hasPreviousPage: false, 116 | hasNextPage: true, 117 | startCursor: 'cursor-10', 118 | endCursor: 'cursor-12', 119 | }, 120 | }); 121 | }); 122 | 123 | it('return items after after-cursor', () => { 124 | return expect( 125 | testPaginate({ 126 | sort, 127 | after: 'cursor-30', 128 | }) 129 | ).resolves.toEqual({ 130 | edges: ['cursor-31', 'cursor-32', 'cursor-33', 'cursor-34'], 131 | pageInfo: { 132 | hasPreviousPage: true, 133 | hasNextPage: false, 134 | startCursor: 'cursor-31', 135 | endCursor: 'cursor-34', 136 | }, 137 | }); 138 | }); 139 | 140 | it('return first 3 items after after-cursor', () => { 141 | return expect( 142 | testPaginate({ 143 | sort, 144 | after: 'cursor-20', 145 | first: 3, 146 | }) 147 | ).resolves.toEqual({ 148 | edges: ['cursor-21', 'cursor-22', 'cursor-23'], 149 | pageInfo: { 150 | hasPreviousPage: true, 151 | hasNextPage: true, 152 | startCursor: 'cursor-21', 153 | endCursor: 'cursor-23', 154 | }, 155 | }); 156 | }); 157 | 158 | it('hasNextPage=false because first is greater than the remaining items', () => { 159 | return expect( 160 | testPaginate({ 161 | sort, 162 | after: 'cursor-32', 163 | first: 10, 164 | }) 165 | ).resolves.toEqual({ 166 | edges: ['cursor-33', 'cursor-34'], 167 | pageInfo: { 168 | hasPreviousPage: true, 169 | hasNextPage: false, 170 | startCursor: 'cursor-33', 171 | endCursor: 'cursor-34', 172 | }, 173 | }); 174 | }); 175 | 176 | it('when beginsWith=cursor-2, after=5 and first=6, return hasNextPage=false because items were filtered', () => { 177 | return expect( 178 | testPaginate({ 179 | sort, 180 | beginsWith: 'cursor-2', 181 | after: '5', 182 | first: 6, 183 | }) 184 | ).resolves.toEqual({ 185 | edges: ['6', '7', '8', '9'], 186 | pageInfo: { 187 | hasPreviousPage: true, 188 | hasNextPage: false, 189 | startCursor: '6', 190 | endCursor: '9', 191 | }, 192 | }); 193 | }); 194 | }); 195 | 196 | describe('backward pagination', () => { 197 | it('return no items because before-cursor is before first item', () => { 198 | return expect( 199 | testPaginate({ before: 'cursor-09', sort }) 200 | ).resolves.toEqual({ 201 | edges: [], 202 | pageInfo: { 203 | hasNextPage: true, 204 | hasPreviousPage: false, 205 | }, 206 | }); 207 | }); 208 | 209 | it('return last 3 items (before-cursor undefined)', () => { 210 | return expect( 211 | testPaginate({ 212 | sort, 213 | last: 3, 214 | }) 215 | ).resolves.toEqual({ 216 | edges: ['cursor-32', 'cursor-33', 'cursor-34'], 217 | pageInfo: { 218 | hasPreviousPage: true, 219 | hasNextPage: false, 220 | startCursor: 'cursor-32', 221 | endCursor: 'cursor-34', 222 | }, 223 | }); 224 | }); 225 | 226 | it('return items before before-cursor', () => { 227 | return expect( 228 | testPaginate({ 229 | sort, 230 | before: 'cursor-16', 231 | }) 232 | ).resolves.toEqual({ 233 | edges: [ 234 | 'cursor-10', 235 | 'cursor-11', 236 | 'cursor-12', 237 | 'cursor-13', 238 | 'cursor-14', 239 | 'cursor-15', 240 | ], 241 | pageInfo: { 242 | hasPreviousPage: false, 243 | hasNextPage: true, 244 | startCursor: 'cursor-10', 245 | endCursor: 'cursor-15', 246 | }, 247 | }); 248 | }); 249 | 250 | it('return last 3 items before before-cursor', () => { 251 | return expect( 252 | testPaginate({ 253 | sort, 254 | before: 'cursor-16', 255 | last: 3, 256 | }) 257 | ).resolves.toEqual({ 258 | edges: ['cursor-13', 'cursor-14', 'cursor-15'], 259 | pageInfo: { 260 | hasPreviousPage: true, 261 | hasNextPage: true, 262 | startCursor: 'cursor-13', 263 | endCursor: 'cursor-15', 264 | }, 265 | }); 266 | }); 267 | 268 | it('hasPreviousPage=false because last is greater than the remaining items', () => { 269 | return expect( 270 | testPaginate({ 271 | sort, 272 | before: 'cursor-12', 273 | last: 10, 274 | }) 275 | ).resolves.toEqual({ 276 | edges: ['cursor-10', 'cursor-11'], 277 | pageInfo: { 278 | hasPreviousPage: false, 279 | hasNextPage: true, 280 | startCursor: 'cursor-10', 281 | endCursor: 'cursor-11', 282 | }, 283 | }); 284 | }); 285 | 286 | it('when beginsWith=cursor-2, before=5 and last=6, return hasPreviousPage=false because items were filtered', () => { 287 | return expect( 288 | testPaginate({ 289 | sort, 290 | beginsWith: 'cursor-2', 291 | before: '5', 292 | last: 6, 293 | }) 294 | ).resolves.toEqual({ 295 | edges: ['0', '1', '2', '3', '4'], 296 | pageInfo: { 297 | hasPreviousPage: false, 298 | hasNextPage: true, 299 | startCursor: '0', 300 | endCursor: '4', 301 | }, 302 | }); 303 | }); 304 | }); 305 | }); 306 | 307 | describe('descending sorting', () => { 308 | const sort = 'DESC' as const; 309 | 310 | describe('forward pagination', () => { 311 | it('return no items because after-cursor is after last item', () => { 312 | return expect( 313 | testPaginate({ after: 'cursor-09', sort }) 314 | ).resolves.toEqual({ 315 | edges: [], 316 | pageInfo: { 317 | hasNextPage: false, 318 | hasPreviousPage: true, 319 | }, 320 | }); 321 | }); 322 | 323 | it('return first 3 items (after-cursor undefined)', () => { 324 | return expect( 325 | testPaginate({ 326 | sort, 327 | first: 3, 328 | }) 329 | ).resolves.toEqual({ 330 | edges: ['cursor-34', 'cursor-33', 'cursor-32'], 331 | pageInfo: { 332 | hasPreviousPage: false, 333 | hasNextPage: true, 334 | startCursor: 'cursor-34', 335 | endCursor: 'cursor-32', 336 | }, 337 | }); 338 | }); 339 | 340 | it('return items after after-cursor', () => { 341 | return expect( 342 | testPaginate({ 343 | sort, 344 | after: 'cursor-15', 345 | }) 346 | ).resolves.toEqual({ 347 | edges: [ 348 | 'cursor-14', 349 | 'cursor-13', 350 | 'cursor-12', 351 | 'cursor-11', 352 | 'cursor-10', 353 | ], 354 | pageInfo: { 355 | hasPreviousPage: true, 356 | hasNextPage: false, 357 | startCursor: 'cursor-14', 358 | endCursor: 'cursor-10', 359 | }, 360 | }); 361 | }); 362 | 363 | it('return first 3 items after after-cursor', () => { 364 | return expect( 365 | testPaginate({ 366 | sort, 367 | after: 'cursor-14', 368 | first: 3, 369 | }) 370 | ).resolves.toEqual({ 371 | edges: ['cursor-13', 'cursor-12', 'cursor-11'], 372 | pageInfo: { 373 | hasPreviousPage: true, 374 | hasNextPage: true, 375 | startCursor: 'cursor-13', 376 | endCursor: 'cursor-11', 377 | }, 378 | }); 379 | }); 380 | 381 | it('when beginsWith=cursor-2, after=5 and first=6, return hasNextPage=false because items were filtered', () => { 382 | return expect( 383 | testPaginate({ 384 | sort, 385 | beginsWith: 'cursor-2', 386 | after: '5', 387 | first: 6, 388 | }) 389 | ).resolves.toEqual({ 390 | edges: ['4', '3', '2', '1', '0'], 391 | pageInfo: { 392 | hasPreviousPage: true, 393 | hasNextPage: false, 394 | startCursor: '4', 395 | endCursor: '0', 396 | }, 397 | }); 398 | }); 399 | }); 400 | 401 | describe('backward pagination', () => { 402 | it('return no items because before-cursor is before first item', () => { 403 | return expect( 404 | testPaginate({ before: 'cursor-35', sort }) 405 | ).resolves.toEqual({ 406 | edges: [], 407 | pageInfo: { 408 | hasNextPage: true, 409 | hasPreviousPage: false, 410 | }, 411 | }); 412 | }); 413 | 414 | it('return last 3 items (before-cursor undefined)', () => { 415 | return expect( 416 | testPaginate({ 417 | sort, 418 | last: 3, 419 | }) 420 | ).resolves.toEqual({ 421 | edges: ['cursor-12', 'cursor-11', 'cursor-10'], 422 | pageInfo: { 423 | hasPreviousPage: true, 424 | hasNextPage: false, 425 | startCursor: 'cursor-12', 426 | endCursor: 'cursor-10', 427 | }, 428 | }); 429 | }); 430 | 431 | it('return items before before-cursor', () => { 432 | return expect( 433 | testPaginate({ 434 | sort, 435 | before: 'cursor-31', 436 | }) 437 | ).resolves.toEqual({ 438 | edges: ['cursor-34', 'cursor-33', 'cursor-32'], 439 | pageInfo: { 440 | hasPreviousPage: false, 441 | hasNextPage: true, 442 | startCursor: 'cursor-34', 443 | endCursor: 'cursor-32', 444 | }, 445 | }); 446 | }); 447 | 448 | it('return last 3 items before before-cursor', () => { 449 | return expect( 450 | testPaginate({ 451 | sort, 452 | before: 'cursor-30', 453 | last: 3, 454 | }) 455 | ).resolves.toEqual({ 456 | edges: ['cursor-33', 'cursor-32', 'cursor-31'], 457 | pageInfo: { 458 | hasPreviousPage: true, 459 | hasNextPage: true, 460 | startCursor: 'cursor-33', 461 | endCursor: 'cursor-31', 462 | }, 463 | }); 464 | }); 465 | 466 | it('when beginsWith=cursor-2, before=5 and last=6, return hasPreviousPage=false because items was filtered', () => { 467 | return expect( 468 | testPaginate({ 469 | sort, 470 | beginsWith: 'cursor-2', 471 | before: '5', 472 | last: 6, 473 | }) 474 | ).resolves.toEqual({ 475 | edges: ['9', '8', '7', '6'], 476 | pageInfo: { 477 | hasPreviousPage: false, 478 | hasNextPage: true, 479 | startCursor: '9', 480 | endCursor: '6', 481 | }, 482 | }); 483 | }); 484 | }); 485 | }); 486 | }); 487 | 488 | describe('projection expression', () => { 489 | const first = 1; 490 | test('return only hashKey', async () => { 491 | const response = await paginate({ 492 | ...defaultQueryParams, 493 | first, 494 | projectionExpression: hashKeyName, 495 | }); 496 | expect(Object.keys(response.edges[0].node)).toEqual([hashKeyName]); 497 | }); 498 | 499 | test('return only hashKey and rangeKey', async () => { 500 | const response = await paginate({ 501 | ...defaultQueryParams, 502 | first, 503 | projectionExpression: [hashKeyName, rangeKeyName].join(','), 504 | }); 505 | expect(Object.keys(response.edges[0].node).sort()).toEqual( 506 | [hashKeyName, rangeKeyName].sort() 507 | ); 508 | }); 509 | }); 510 | 511 | describe('filters', () => { 512 | test('return only items whose cursor > 20', async () => { 513 | const { pageInfo } = await paginate({ 514 | ...defaultQueryParams, 515 | filterExpression: '#index > :index', 516 | filterAttributeNames: { 517 | '#index': 'index', 518 | }, 519 | filterAttributeValues: { 520 | ':index': 20, 521 | }, 522 | }); 523 | expect(pageInfo).toEqual( 524 | expect.objectContaining({ 525 | startCursor: 'cursor-34', 526 | endCursor: 'cursor-21', 527 | }) 528 | ); 529 | }); 530 | 531 | test('return only even cursors', async () => { 532 | const { edges } = await paginate({ 533 | ...defaultQueryParams, 534 | filterExpression: '#parity = :parity', 535 | filterAttributeNames: { 536 | '#parity': 'parity', 537 | }, 538 | filterAttributeValues: { 539 | ':parity': 'EVEN', 540 | }, 541 | }); 542 | const parityValues = Array.from( 543 | new Set<{ parity: string }>(edges.map(({ node }) => node.parity)) 544 | ); 545 | expect(parityValues).toEqual(['EVEN']); 546 | }); 547 | }); 548 | -------------------------------------------------------------------------------- /test/populateTable.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDB } from 'aws-sdk'; 2 | import dotenv from 'dotenv'; 3 | 4 | import { NUMBER_OF_ITEMS } from './config'; 5 | 6 | dotenv.config(); 7 | 8 | const { 9 | HASH_KEY_NAME, 10 | HASH_KEY_VALUE, 11 | RANGE_KEY_NAME, 12 | TABLE_NAME, 13 | REGION, 14 | } = process.env; 15 | 16 | const documentClient = new DynamoDB.DocumentClient({ region: REGION }); 17 | 18 | const items = [...new Array(NUMBER_OF_ITEMS)].map((_, index) => { 19 | /** 20 | * +10 to create id with same string length. 21 | */ 22 | const newIndex = index + 10; 23 | const rangeKeyValue = `cursor-${newIndex}`; 24 | return { 25 | [HASH_KEY_NAME]: HASH_KEY_VALUE, 26 | [RANGE_KEY_NAME]: rangeKeyValue, 27 | index: newIndex, 28 | parity: newIndex & 1 ? 'ODD' : 'EVEN', 29 | }; 30 | }); 31 | 32 | (async () => { 33 | await documentClient 34 | .batchWrite({ 35 | RequestItems: { 36 | [TABLE_NAME]: items.map(item => ({ PutRequest: { Item: item } })), 37 | }, 38 | }) 39 | .promise(); 40 | })(); 41 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // transpile JSX to React.createElement 25 | "jsx": "react", 26 | // interop between ESM and CJS modules. Recommended by TS 27 | "esModuleInterop": true, 28 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 29 | "skipLibCheck": true, 30 | // error out if import and file system have a casing mismatch. Recommended by TS 31 | "forceConsistentCasingInFileNames": true, 32 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 33 | "noEmit": true 34 | } 35 | } 36 | --------------------------------------------------------------------------------