Crud Api w/ Server Side Pagination with NestJS & Angular - Pagination
Published on
In our previous post we create a basic Crud API with an endpoint to pull all users from the database. Pulling all users may not not be the best solution especially if millions of rows exist in the users table. A common approach to solving this to use pagination. You can think of pagination as only getting the page of a book that you need rather than getting the whole book.
Having worked with Java/Spring in the past, I tend to go to the Spring framework when having to implement such features that Spring supports out of the box. Our implementation of pagination will be heavily be based on how this is implemented in Spring but also heavily simplified with less abstraction as well.
Prerquisites
The following dependencies are required for this project.
Setup
This post builds upon our previous post here. You can also clone this Github repository.
Creating Our Server Side Pagination
To handle pagination on the server side we need to create serveral model classes. First we need to model the page that is being requested. Our endpoint will use a query string to determine which page the use wants returned and how the elements should be ordered.
First we are going to create a common module and some directories to keep our files organized. From the project’s server
directory, run the following…
nest generate module common
mkdir -p src/common/models/pagination
touch src/common/models/pagination/page-request.model.ts \
src/common/models/pagination/page.model.ts \
src/common/models/pagination/sort.model.ts \
src/common/models/pagination/sort-direction.enum.ts
Our page request model will look something like this…
import { Sort } from './sort.model';
export class PageRequest {
public page: number;
public size: number;
public sort: Sort;
constructor(page: number = 1, size: number = 10, sort: Sort = new Sort()) {
this.page = page;
this.size = size;
this.sort = sort;
}
public next(totalElements:number): PageRequest {
const totalPages: number = Math.ceil(totalElements / this.size) || 1;
const nextPage: number = +this.page === totalPages ? 1 : +this.page + 1;
return new PageRequest(nextPage, this.size, this.sort);
}
public previous(totalElements: number): PageRequest {
const totalPages: number = Math.ceil(totalElements / this.size || 1);
const previousPage: number = +this.page=== 1 ? totalPages : +this.page- 1;
return new PageRequest(previousPage, this.size, this.sort);
}
public static from(page: number, size: number, sortColumn: string, sortDirection: string): PageRequest {
const sort: Sort = Sort.from(sortColumn, sortDirection);
return new PageRequest(page, size, sort);
}
}
The page request model has properties to hold the page number, size, and a Sort
which we will create next. It also contains class methods to get the next page and previous page as well as a static utility method to create a PageRequest
object from primitive values (our query string params).
Now we can create our Sort
model and our SortDirection
enum value…
import { SortDirection } from './sort-direction.enum';
export class Sort {
public direction: SortDirection;
public column: string;
constructor(column: string = 'id', direction: SortDirection = SortDirection.ASCENDING) {
this.direction = direction;
this.column = column;
}
public getSortDirection(): SortDirection {
return this.direction;
}
public getSortColumn(): string {
return this.column;
}
public asKeyValue(): { [key: string]: string } {
return {
[this.getSortColumn()]: this.getSortDirection()
};
}
public static from(column: string, direction: string): Sort {
switch (direction.toUpperCase()) {
case 'ASC': return new Sort(column, SortDirection.ASCENDING);
case 'DESC': return new Sort(column, SortDirection.DESCENDING);
default: return new Sort(column, SortDirection.ASCENDING);
}
}
}
And our SortDirection
enum
export enum SortDirection {
ASCENDING = 'ASC',
DESCENDING = 'DESC'
}
Our Sort
class simply hold the column which we want to sort by and the direction of the sort (ASC, DESC). Again we are using a static utility method to create a new sort from primitive values.
And finally we can create our Page
model which will model a page of data…
import { PageRequest } from './page-request.model';
export class Page<T> {
public elements: T[];
public totalElements: number;
public totalPages: number;
public current: PageRequest;
public next: PageRequest;
public previous: PageRequest;
constructor(obj: any) {
Object.assign(this, obj);
}
public static from<T>(elements: T[], totalElements: number, pageRequest: PageRequest): Page<T> {
return new Page<T>({
elements: elements,
totalElements: totalElements,
totalPages: Math.ceil(totalElements / pageRequest.size),
current: pageRequest,
next: pageRequest.next(totalElements),
previous: pageRequest.previous(totalElements)
});
}
}
Our Page
class holds the elements of the current requested page, the total number of elements, the total number of pages, the current page request, the next page requrest, and finally the previous page request. We again are using a utility method, from, to help generate new pages.
Updating Our Controller & Service
Now that we have our models in place, we can create our endpoint and service methods. Below is our controller with our crud operations omitted for brevity…
import { Body, Controller, Get, Put, Delete, Logger, Post, Param, Query } from '@nestjs/common';
import { UsersService } from './users.service';
import { User } from './user.entity';
import { CreateUserDto } from './dtos/create-user.dto';
import { UpdateUserDto } from './dtos/update-user.dto';
import { Page } from '../common/models/pagination/page.model';
import { PageRequest } from '../common/models/pagination/page-request.model';
import { SortDirection } from '../common/models/pagination/sort-direction.enum';
import { Sort } from '../common/models/pagination/sort.model';
@Controller('users')
export class UsersController {
constructor(
private readonly _logger: Logger,
private readonly _usersService: UsersService
) {
this._logger.setContext(this.constructor.name);
}
// OMITTED
@Get()
public async getAllUsersByPage(
@Query('pageNumber') pageNumber: number = 1,
@Query('pageSize') pageSize: number = 10,
@Query('sortCol') sortCol: string = 'id',
@Query('sortDir') sortDir: SortDirection = SortDirection.ASCENDING): Promise<Page<User>> {
try {
const pageRequest: PageRequest = PageRequest.from(pageNumber, pageSize, sortCol, sortDir);
return this._usersService.getAllUsersByPage(pageRequest);
} catch (error) {
this._logger.error(error);
}
}
// OMITTED
}
The getAllUsersByPage
method replaces our previous getAllUsers
and provides a paginated result.
Our controller endpoint method expects several query parameters; pageNumber
, pageSize
, sortCol
, and sortDir
. We’ve provided basic default values if any one of the params is not provided.
Now all we have left is to create our service method to query the database and construct the Page
result. Below is our service method with previous methods being omitted for brevity…
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { PageRequest } from 'src/common/models/pagination/page-request.model';
import { Page } from 'src/common/models/pagination/page.model';
import { Repository } from 'typeorm';
import { CreateUserDto } from './dtos/create-user.dto';
import { UpdateUserDto } from './dtos/update-user.dto';
import { UserNotFoundException } from './exceptions/user-not-found.exception';
import { User } from './user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly _usersRepository: Repository<User>
) {}
// OMITTED
public async getAllUsersByPage(pageRequest: PageRequest): Promise<Page<User>> {
const sort: {[key: string]: string} = pageRequest.sort.asKeyValue();
const result = await this._usersRepository.findAndCount({
order: sort,
skip: ((pageRequest.page - 1) * pageRequest.size),
take: pageRequest.size
});
return Page.from(result[0], result[1], pageRequest);
}
}
One of the nice things about TypeORM repostiories is that it provides a convenient method findAndCount
which returns an array with the first element being the resulting elements based on our skip
and take
and the second being the total number of elements ignoring our skip
and take
.
With the result from our query, we can use our static utility method from our Page
class to generate our page.
Our service now supports the following crud enpoints…
Path | Method | Description |
---|---|---|
/users |
GET | Get a page of users. |
/users |
POST | Create a new user |
/users/:id |
GET | Get a new user by id |
/users/:id |
PUT | Update a new user by id |
/users/:id |
DELETE | Delete a new user by id |
Our
GET:/users
endpoint supports query params forpageNumber
,pageSize
,sortCol
andsortDir
.
We can run our server with npm start
. Using a web brower, navigate to http://localhost:3000/users
, and we will receive a page of users with all default query param values applied. A JSON result resembling the blob below will be returned…
{
"elements": [
{
"id": 1,
"firstName": "Anthiathia",
"lastName": "Ewbanke",
"email": "aewbanke3@privacy.gov.au"
},
// OMITTED 9 ELEMENTS
],
"totalElements": 30,
"totalPages": 3,
"current": {
"page": 1,
"size": 10,
"sort": {
"direction": "ASC",
"column": "id"
}
},
"next": {
"page": 2,
"size": 10,
"sort": {
"direction": "ASC",
"column": "id"
}
},
"previous": {
"page": 3,
"size": 10,
"sort": {
"direction": "ASC",
"column": "id"
}
}
}
One thing you’ll notice is that our previous
page circles back the the last page when you are on the first page. Likewise the the next page will circle back the first page when you’re on the last page. You can modify PageRequest
’s next and previous method if you don’t want the circular paging.
Conclusion
Next we’ll create our Angular client to interact with a Api and demonstrate this paginated functionaly with a table of users.
Final Github Repository