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.

  1. NodeJS
  2. Angular CLI
  3. NestJS CLI

 

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 for pageNumber, pageSize, sortCol and sortDir.

 

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

 

comments powered by Disqus