Skip to content

Commit 32c3876

Browse files
authored
feat: improve cursor pagination (#1066)
BREAKING CHANGE: - Removed `cursorableColumns` from `PaginateConfig` in favor of using `sortableColumns` exclusively. - `PaginateQuery` no longer accepts `cursorColumn` or `cursorDirection`; direction is now inferred from `sortBy`. - Removed `firstCursor` and `lastCursor` from `Paginated.meta`; only `cursor` is retained. - Navigation links now rely on the new composite cursor format and dynamic sort direction.
1 parent 2b717aa commit 32c3876

18 files changed

+1187
-512
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ npm-debug.log
66
.history
77
.idea/
88
.env
9+
test.sql

README.md

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ The following code exposes a route using cursor-based pagination:
101101
#### Endpoint
102102

103103
```url
104-
http://localhost:3000/cats?limit=5&cursor=2022-12-20T10:00:00.000Z&cursorColumn=lastVetVisit&cursorDirection=after
104+
http://localhost:3000/cats?limit=5&sortBy=lastVetVisit:ASC&cursor=V998328469600000
105105
```
106106

107107
#### Result
@@ -137,14 +137,12 @@ http://localhost:3000/cats?limit=5&cursor=2022-12-20T10:00:00.000Z&cursorColumn=
137137
],
138138
"meta": {
139139
"itemsPerPage": 5,
140-
"cursor": "2022-12-20T10:00:00.000Z",
141-
"firstCursor": "2022-12-21T10:00:00.000Z",
142-
"lastCursor": "2022-12-25T10:00:00.000Z"
140+
"cursor": "V998328469600000"
143141
},
144142
"links": {
145-
"previous": "http://localhost:3000/cats?limit=5&sortBy=lastVetVisit:ASC&cursor=2022-12-21T10:00:00.000Z&cursorColumn=lastVetVisit&cursorDirection=before",
146-
"current": "http://localhost:3000/cats?limit=5&sortBy=lastVetVisit:ASC&cursor=2022-12-20T10:00:00.000Z&cursorColumn=lastVetVisit&cursorDirection=after",
147-
"next": "http://localhost:3000/cats?limit=5&sortBy=lastVetVisit:ASC&cursor=2022-12-25T10:00:00.000Z&cursorColumn=lastVetVisit&cursorDirection=after"
143+
"previous": "http://localhost:3000/cats?limit=5&sortBy=lastVetVisit:DESC&cursor=V001671616800000",
144+
"current": "http://localhost:3000/cats?limit=5&sortBy=lastVetVisit:ASC&cursor=V998328469600000",
145+
"next": "http://localhost:3000/cats?limit=5&sortBy=lastVetVisit:ASC&cursor=V998328037600000"
148146
}
149147
}
150148
```
@@ -288,17 +286,6 @@ const paginateConfig: PaginateConfig<CatEntity> {
288286
*/
289287
filterableColumns: { age: [FilterOperator.EQ, FilterOperator.IN] },
290288

291-
/**
292-
* Required: false
293-
* Type: (keyof CatEntity)[]
294-
* Default: None
295-
* Description: Columns that can be used as cursors for cursor-based pagination.
296-
* Typically used with date or unique & sequential columns like 'lastVetVisit' or 'id'.
297-
* If `cursorColumn` is not provided in the query, the first column in this array is used as the default.
298-
* If `cursorDirection` is not provided in the query, 'before' is used as the default direction.
299-
*/
300-
cursorableColumns: ['lastVetVisit'],
301-
302289
/**
303290
* Required: false
304291
* Type: RelationColumn<CatEntity>
@@ -329,7 +316,6 @@ const paginateConfig: PaginateConfig<CatEntity> {
329316
* Description: Allow user to choose between limit/offset and take/skip, or cursor-based pagination.
330317
* Default: PaginationType.TAKE_AND_SKIP
331318
* Options: PaginationType.LIMIT_AND_OFFSET, PaginationType.TAKE_AND_SKIP, PaginationType.CURSOR
332-
* Note: CURSOR requires `cursorableColumns` to be defined.
333319
*
334320
* However, using limit/offset can cause problems with relations.
335321
*/
@@ -574,6 +560,38 @@ is resolved to:
574560

575561
`WHERE ... AND (id = 5 OR id = 7) AND name = 'Milo' AND ...`
576562

563+
## Cursor-based Pagination
564+
565+
- `paginationType: PaginationType.CURSOR`
566+
- Cursor format:
567+
- Numbers: `[prefix1][integer:11 digits][prefix2][decimal:4 digits]` (e.g., `Y00000000001V2500` for -1.25 in ASC).
568+
- Dates: `[prefix][value:15 digits]` (e.g., `V001671444000000` for a timestamp in DESC).
569+
- Prefixes:
570+
- `null`: `A` (lowest priority, last in results).
571+
- ASC:
572+
- positive-int: `V` (greater than or equal to 1), `X` (less than 1)
573+
- positive-decimal: `V` (not zero), `X` (zero)
574+
- zero-int: `X`
575+
- zero-decimal: `X`
576+
- negative-int: `Y`
577+
- negative-decimal: `V`
578+
- DESC:
579+
- positive-int: `V`
580+
- positive-decimal: `V`
581+
- zero-int: `N`
582+
- zero-decimal: `X`
583+
- negative-int: `M` (less than or equal to -1), `N` (greater than -1)
584+
- negative-decimal: `V` (not zero), `X` (zero)
585+
- Logic:
586+
- Numbers: Split into integer (11 digits) and decimal (4 digits) parts, with separate prefixes. Supports negative values, with sorting adjusted per direction.
587+
- Dates: Single prefix with 15-digit timestamp padded with zeros.
588+
- ASC: Negative → Zero → Positive → Null.
589+
- DESC: Positive → Zero → Negative → Null.
590+
- Notes:
591+
- Multiple columns: `sortBy` can include multiple columns to create and sort by the cursor (e.g., `sortBy=age:ASC,createdAt:DESC`), but at least one column must be unique to ensure consistent ordering.
592+
- Supported columns: Cursor sorting is available for numeric and date-related columns (string columns are not supported).
593+
- Decimal support: Numeric columns can include decimals, limited to 11 digits for the integer part and 4 digits for the decimal part.
594+
577595
## Swagger
578596

579597
You can use two default decorators @ApiOkResponsePaginated and @ApiPagination to generate swagger documentation for your endpoints

package-lock.json

Lines changed: 118 additions & 48 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"eslint-plugin-prettier": "^5.2.3",
4848
"fastify": "^5.2.1",
4949
"jest": "^29.7.0",
50-
"mysql": "^2.18.1",
50+
"mysql2": "^3.14.0",
5151
"pg": "^8.14.0",
5252
"prettier": "^3.0.3",
5353
"reflect-metadata": "^0.2.2",

src/__tests__/cat-hair.entity.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Column, CreateDateColumn, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm'
2+
import { DateColumnNotNull } from './column-option'
23

34
@Entity()
45
export class CatHairEntity {
@@ -11,7 +12,7 @@ export class CatHairEntity {
1112
@Column({ type: 'text', array: true, default: '{}' })
1213
colors: string[]
1314

14-
@CreateDateColumn()
15+
@CreateDateColumn(DateColumnNotNull)
1516
createdAt: string
1617

1718
@Column({ type: 'jsonb', nullable: true })

src/__tests__/cat-home-pillow.entity.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'
2-
import { CatHomeEntity } from './cat-home.entity'
32
import { CatHomePillowBrandEntity } from './cat-home-pillow-brand.entity'
3+
import { CatHomeEntity } from './cat-home.entity'
4+
import { DateColumnNotNull } from './column-option'
45

56
@Entity()
67
export class CatHomePillowEntity {
@@ -16,6 +17,6 @@ export class CatHomePillowEntity {
1617
@ManyToOne(() => CatHomePillowBrandEntity)
1718
brand: CatHomePillowBrandEntity
1819

19-
@CreateDateColumn()
20+
@CreateDateColumn(DateColumnNotNull)
2021
createdAt: string
2122
}

0 commit comments

Comments
 (0)