Skip to content

Commit ff6e10d

Browse files
committed
perf: improve paging query performance
- use inner join on sub query paging table to prevent TypeORM executes id IN (...) query
1 parent cc881ed commit ff6e10d

File tree

8 files changed

+135
-49
lines changed

8 files changed

+135
-49
lines changed

src/Paginator.ts

+31-16
Original file line numberDiff line numberDiff line change
@@ -113,35 +113,50 @@ export default class Paginator<Entity> {
113113
Object.assign(cursors, this.decode(this.beforeCursor as string));
114114
}
115115

116-
if (Object.keys(cursors).length > 0) {
117-
builder.addFrom((pagingQuery) => pagingQuery
116+
builder.innerJoin((paging) => {
117+
const pagingSubQuery = paging
118+
.from(this.entity, 'paging')
118119
.select(this.paginationKeys)
119-
.from(this.entity, this.alias)
120-
.andWhere(new Brackets((where) => this.buildCursor(where, cursors))),
121-
'paging');
120+
.limit(this.limit + 1)
121+
.orderBy(this.buildOrder('paging'));
122122

123-
this.paginationKeys.forEach((key) => {
124-
builder.andWhere(`${this.alias}.${key} = paging.${key}`);
125-
});
126-
}
123+
if (Object.keys(cursors).length > 0) {
124+
pagingSubQuery
125+
.andWhere(new Brackets((where) => this.buildCursorQuery(where, cursors)));
126+
}
127+
128+
return pagingSubQuery;
129+
},
130+
'paging',
131+
this.buildPagingInnerJoinCondition());
127132

128-
builder.take(this.limit + 1);
129-
builder.orderBy(this.buildOrder());
133+
builder.orderBy(this.buildOrder(this.alias));
130134

131135
return builder;
132136
}
133137

134-
private buildCursor(where: WhereExpression, cursors: CursorParam): void {
138+
private buildCursorQuery(where: WhereExpression, cursors: CursorParam): void {
135139
const operator = this.getOperator();
136140
const params: CursorParam = {};
137141
let query = '';
138142
this.paginationKeys.forEach((key) => {
139143
params[key] = cursors[key];
140-
where.orWhere(`${query}${this.alias}.${key} ${operator} :${key}`, params);
141-
query = `${query}${this.alias}.${key} = :${key} AND `;
144+
where.orWhere(`${query}paging.${key} ${operator} :${key}`, params);
145+
query = `${query}paging.${key} = :${key} AND `;
142146
});
143147
}
144148

149+
private buildPagingInnerJoinCondition(): string {
150+
return this.paginationKeys.reduce((prev, next) => {
151+
let query = `${this.alias}.${next} = paging.${next}`;
152+
if (prev !== '') {
153+
query = `AND ${query}`;
154+
}
155+
156+
return `${prev} ${query}`;
157+
}, '');
158+
}
159+
145160
private getOperator(): string {
146161
if (this.hasAfterCursor()) {
147162
return this.order === 'ASC' ? '>' : '<';
@@ -154,7 +169,7 @@ export default class Paginator<Entity> {
154169
return '=';
155170
}
156171

157-
private buildOrder(): OrderByCondition {
172+
private buildOrder(alias: string): OrderByCondition {
158173
let { order } = this;
159174

160175
if (!this.hasAfterCursor() && this.hasBeforeCursor()) {
@@ -163,7 +178,7 @@ export default class Paginator<Entity> {
163178

164179
const orderByCondition: OrderByCondition = {};
165180
this.paginationKeys.forEach((key) => {
166-
orderByCondition[`${this.alias}.${key}`] = order;
181+
orderByCondition[`${alias}.${key}`] = order;
167182
});
168183

169184
return orderByCondition;

src/utils.ts

+1-5
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function encodeByType(type: string, value: any): string | null {
1414
return (value as Date).getTime().toString();
1515
}
1616
case 'number': {
17-
return (value as number).toString();
17+
return `${value}`;
1818
}
1919
case 'string': {
2020
return encodeURIComponent(value);
@@ -75,10 +75,6 @@ export function camelOrPascalToUnderscore(str: string): string {
7575
return str.split(/(?=[A-Z])/).join('_').toLowerCase();
7676
}
7777

78-
export function camelToUnderscore(str: string): string {
79-
return camelOrPascalToUnderscore(str);
80-
}
81-
8278
export function pascalToUnderscore(str: string): string {
8379
return camelOrPascalToUnderscore(str);
8480
}

test/entities/Example.ts

-10
This file was deleted.

test/entities/Photo.ts

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import {
2+
Entity,
3+
Column,
4+
PrimaryGeneratedColumn,
5+
ManyToOne,
6+
} from 'typeorm';
7+
8+
import { User } from './User';
9+
10+
@Entity({ name: 'photos' })
11+
export class Photo {
12+
@PrimaryGeneratedColumn()
13+
public id!: number;
14+
15+
@Column({
16+
type: 'text',
17+
nullable: false,
18+
})
19+
public link!: string;
20+
21+
@ManyToOne(
22+
() => User,
23+
(user) => user.photos,
24+
)
25+
public user!: User;
26+
}

test/entities/User.ts

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {
2+
Entity,
3+
Column,
4+
PrimaryGeneratedColumn,
5+
OneToMany,
6+
} from 'typeorm';
7+
8+
import { Photo } from './Photo';
9+
10+
@Entity({ name: 'users' })
11+
export class User {
12+
@PrimaryGeneratedColumn()
13+
public id!: number;
14+
15+
@Column({
16+
type: 'varchar',
17+
length: 255,
18+
nullable: false,
19+
})
20+
public name!: string;
21+
22+
@Column({
23+
type: 'timestamp',
24+
nullable: false,
25+
})
26+
public timestamp!: Date
27+
28+
@OneToMany(
29+
() => Photo,
30+
(photo) => photo.user,
31+
{
32+
cascade: true,
33+
},
34+
)
35+
public photos!: Photo[]
36+
}

test/integration.ts

+19-14
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { expect } from 'chai';
22
import { createConnection, getConnection } from 'typeorm';
33

44
import { createQueryBuilder } from './utils/createQueryBuilder';
5+
import { prepareData } from './utils/prepareData';
6+
import { User } from './entities/User';
7+
import { Photo } from './entities/Photo';
58
import { buildPaginator } from '../src/index';
6-
import { Example } from './entities/Example';
79

810
describe('TypeORM cursor-based pagination test', () => {
911
before(async () => {
@@ -14,26 +16,28 @@ describe('TypeORM cursor-based pagination test', () => {
1416
username: 'test',
1517
password: 'test',
1618
database: 'test',
17-
entities: [Example],
19+
synchronize: true,
20+
entities: [User, Photo],
1821
logging: true,
1922
});
2023

21-
await getConnection().query('DROP TABLE IF EXISTS example;');
22-
await getConnection().query('CREATE TABLE example as SELECT generate_series(1, 10) AS id;');
24+
await prepareData();
2325
});
2426

2527
it('should paginate correctly with before and after cursor', async () => {
26-
const queryBuilder = createQueryBuilder();
28+
const queryBuilder = createQueryBuilder().leftJoinAndSelect('user.photos', 'photo');
2729
const firstPagePaginator = buildPaginator({
28-
entity: Example,
30+
entity: User,
31+
paginationKeys: ['id', 'name', 'timestamp'],
2932
query: {
3033
limit: 1,
3134
},
3235
});
3336
const firstPageResult = await firstPagePaginator.paginate(queryBuilder.clone());
3437

3538
const nextPagePaginator = buildPaginator({
36-
entity: Example,
39+
entity: User,
40+
paginationKeys: ['id', 'name', 'timestamp'],
3741
query: {
3842
limit: 1,
3943
afterCursor: firstPageResult.cursor.afterCursor as string,
@@ -42,7 +46,8 @@ describe('TypeORM cursor-based pagination test', () => {
4246
const nextPageResult = await nextPagePaginator.paginate(queryBuilder.clone());
4347

4448
const prevPagePaginator = buildPaginator({
45-
entity: Example,
49+
entity: User,
50+
paginationKeys: ['id', 'name', 'timestamp'],
4651
query: {
4752
limit: 1,
4853
beforeCursor: nextPageResult.cursor.beforeCursor as string,
@@ -66,14 +71,14 @@ describe('TypeORM cursor-based pagination test', () => {
6671
it('should return entities with given order', async () => {
6772
const queryBuilder = createQueryBuilder();
6873
const ascPaginator = buildPaginator({
69-
entity: Example,
74+
entity: User,
7075
query: {
7176
limit: 1,
7277
order: 'ASC',
7378
},
7479
});
7580
const descPaginator = buildPaginator({
76-
entity: Example,
81+
entity: User,
7782
query: {
7883
limit: 1,
7984
order: 'DESC',
@@ -90,7 +95,7 @@ describe('TypeORM cursor-based pagination test', () => {
9095
it('should return entities with given limit', async () => {
9196
const queryBuilder = createQueryBuilder();
9297
const paginator = buildPaginator({
93-
entity: Example,
98+
entity: User,
9499
query: {
95100
limit: 10,
96101
},
@@ -102,9 +107,9 @@ describe('TypeORM cursor-based pagination test', () => {
102107
});
103108

104109
it('should return empty array and null cursor if no data', async () => {
105-
const queryBuilder = createQueryBuilder().where('example.id > :id', { id: 10 });
110+
const queryBuilder = createQueryBuilder().where('user.id > :id', { id: 10 });
106111
const paginator = buildPaginator({
107-
entity: Example,
112+
entity: User,
108113
});
109114
const result = await paginator.paginate(queryBuilder);
110115

@@ -114,7 +119,7 @@ describe('TypeORM cursor-based pagination test', () => {
114119
});
115120

116121
after(async () => {
117-
await getConnection().query('TRUNCATE TABLE example;');
122+
await getConnection().query('TRUNCATE TABLE users RESTART IDENTITY CASCADE;');
118123
await getConnection().close();
119124
});
120125
});

test/utils/createQueryBuilder.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { getConnection, SelectQueryBuilder } from 'typeorm';
2-
import { Example } from '../entities/Example';
2+
import { User } from '../entities/User';
33

4-
export function createQueryBuilder(): SelectQueryBuilder<Example> {
4+
export function createQueryBuilder(): SelectQueryBuilder<User> {
55
return getConnection()
6-
.getRepository(Example)
7-
.createQueryBuilder('example');
6+
.getRepository(User)
7+
.createQueryBuilder('user');
88
}

test/utils/prepareData.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { getConnection } from 'typeorm';
2+
import { User } from '../entities/User';
3+
4+
export async function prepareData(): Promise<void> {
5+
const data = [...Array(10).keys()].map((i) => ({
6+
name: `user${i}`,
7+
timestamp: new Date(),
8+
photos: [
9+
{
10+
link: `http://photo.com/${i}`,
11+
},
12+
],
13+
}));
14+
15+
await getConnection()
16+
.getRepository(User)
17+
.save(data);
18+
}

0 commit comments

Comments
 (0)