Pagination redone

This commit is contained in:
danny-mhlv 2022-09-21 15:14:57 +03:00
parent 9e547a0a44
commit d996bf679d
10 changed files with 168 additions and 301 deletions

View File

@ -1,5 +1,5 @@
import { ApiExtraModels, ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ApiExtraModels, ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsArray, IsDefined, IsInt, IsNotEmpty, IsNumber, IsObject, IsOptional } from "class-validator"; import { IsArray, IsDefined, IsInt, IsObject, IsOptional } from "class-validator";
import { EsPit } from "../../interfaces/elastic/es-pit.interface"; import { EsPit } from "../../interfaces/elastic/es-pit.interface";
import { EsQuery } from "../../interfaces/elastic/es-query.interface" import { EsQuery } from "../../interfaces/elastic/es-query.interface"
@ -13,71 +13,80 @@ import { EsQuery } from "../../interfaces/elastic/es-query.interface"
*/ */
@ApiExtraModels() @ApiExtraModels()
export class EsQueryDto { export class EsQueryDto {
/** /**
* Maximum number of elements returned by Elasticsearch * Offset from the start of the list of hits
*/ */
@IsOptional() @IsOptional()
@IsDefined() @IsInt()
@IsNumber() @ApiPropertyOptional({
@IsInt() description: 'Offset from the start of the list of hits',
@ApiPropertyOptional({ example: 5,
description: 'Maximum number of elements returned by Elasticsearch', })
example: 30 from?: number;
})
size?: number;
/**
* The search query object passed to Elasticsearch
*/
@IsDefined()
@IsObject()
@ApiProperty({
description: 'Search query object passed to Elasticsearch',
example: {},
})
query: EsQuery;
/** /**
* Object, that stores PIT ID and time alive * Maximum number of elements returned by Elasticsearch
*/ */
@IsOptional() @IsOptional()
@IsObject() @IsInt()
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'PIT object', description: 'Maximum number of elements returned by Elasticsearch',
example: {} example: 30
}) })
pit?: EsPit; size?: number;
/**
* The search query object passed to Elasticsearch
*/
@IsDefined()
@IsObject()
@ApiProperty({
description: 'Search query object passed to Elasticsearch',
example: {},
})
query: EsQuery;
/** /**
* Sorting info * Object, that stores PIT ID and time alive
*/ */
@IsOptional() @IsOptional()
@IsArray() @IsObject()
@ApiPropertyOptional({ @ApiPropertyOptional({
description: '', description: 'PIT object',
example: [] example: {}
}) })
sort?: unknown[]; pit?: EsPit;
/** /**
* Pagination info * Sorting info
*/ */
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ApiPropertyOptional({ @ApiPropertyOptional({
description: '', description: '',
example: [] example: []
}) })
search_after?: unknown[]; sort?: unknown[];
/** /**
* Constructs an empty object * Pagination info
*/ */
constructor() { @IsOptional()
this.size = 10; @IsArray()
this.query = undefined; @ApiPropertyOptional({
this.pit = undefined; description: '',
this.sort = undefined; example: []
this.search_after = undefined; })
} search_after?: unknown[];
/**
* Constructs an empty object
*/
constructor() {
this.size = 10;
this.query = undefined;
this.pit = undefined;
this.sort = undefined;
this.search_after = undefined;
}
} }

View File

@ -1,8 +1,6 @@
import { ApiExtraModels, ApiProperty, PartialType } from "@nestjs/swagger"; import { ApiExtraModels, ApiProperty } from "@nestjs/swagger";
import { IsArray } from "class-validator"; import { IsArray } from "class-validator";
import { Order } from "../enums"; import { Order } from "../enums";
import { PageMeta } from "../interfaces/page-meta.interface";
import { PaperDto } from "./paper.dto";
/** /**
* List of allowed properties in this DTO * List of allowed properties in this DTO
@ -13,7 +11,7 @@ const allowedProperties = ['total', 'pagenum', 'order', 'hasNext', 'hasPrev', 'p
* Page model for pagination * Page model for pagination
*/ */
@ApiExtraModels() @ApiExtraModels()
export class PageMetaDto implements PageMeta { export class PageMetaDto {
/** /**
* Total number of hits (results) acquired from the search * Total number of hits (results) acquired from the search
*/ */
@ -24,16 +22,6 @@ export class PageMetaDto implements PageMeta {
}) })
total: number; total: number;
/**
* Current page number
*/
@ApiProperty({
description: 'Current page number',
minimum: 1,
example: 3
})
pagenum: number;
/** /**
* Order of the elements on the page * Order of the elements on the page
*/ */
@ -42,32 +30,4 @@ export class PageMetaDto implements PageMeta {
example: Order.DESC example: Order.DESC
}) })
order: Order; order: Order;
/**
* Flag, that shows if there's a page following the current one
*/
@ApiProperty({
description: 'Flag, that shows if there\'s a page following the current one',
example: true
})
hasNext: boolean;
/**
* Flag, that shows if there's a page preceding the current one
*/
@ApiProperty({
description: 'Flag, that shows if there\'s a page preceding the current one',
example: true
})
hasPrev: boolean;
/**
* Maximum number of elements on the page
*/
@ApiProperty({
description: 'Maximum number of elements on the page',
minimum: 1,
example: 20
})
pagesize: number;
} }

View File

@ -1,7 +1,5 @@
import { ApiExtraModels, ApiProperty, PartialType } from "@nestjs/swagger"; import { ApiExtraModels, ApiProperty, PartialType } from "@nestjs/swagger";
import { IsArray } from "class-validator"; import { IsArray } from "class-validator";
import { Order } from "../enums";
import { PageMeta } from "../interfaces/page-meta.interface";
import { PageMetaDto } from "./page-meta.dto"; import { PageMetaDto } from "./page-meta.dto";
import { PaperDto } from "./paper.dto"; import { PaperDto } from "./paper.dto";
@ -22,7 +20,7 @@ export class PageDto {
@ApiProperty({ @ApiProperty({
description: 'All data (papers) the page contains', description: 'All data (papers) the page contains',
isArray: true, isArray: true,
type: PaperDto type: PaperDto,
}) })
readonly data: PaperDto[]; readonly data: PaperDto[];
@ -31,7 +29,7 @@ export class PageDto {
*/ */
@ApiProperty({ @ApiProperty({
description: 'Metadata for the page', description: 'Metadata for the page',
// example: {}, type: PageMetaDto,
}) })
readonly meta: PageMetaDto; readonly meta: PageMetaDto;
@ -41,7 +39,7 @@ export class PageDto {
* @param data * @param data
* @param meta * @param meta
*/ */
constructor(data: PaperDto[], meta: PageMeta) { constructor(data: PaperDto[], meta: PageMetaDto) {
this.data = data; this.data = data;
this.meta = meta; this.meta = meta;
} }

View File

@ -1,4 +1,4 @@
import { ApiExtraModels, ApiProperty } from "@nestjs/swagger"; import { ApiExtraModels, ApiPropertyOptional } from "@nestjs/swagger";
import { IsDefined, IsInt, IsNotEmpty, IsOptional, IsString } from "class-validator"; import { IsDefined, IsInt, IsNotEmpty, IsOptional, IsString } from "class-validator";
/** /**
@ -12,63 +12,64 @@ const allowedProperties = ['query', 'pagen', 'limit', 'order'];
@ApiExtraModels() @ApiExtraModels()
export class SearchQueryDto { export class SearchQueryDto {
/** /**
* Given query string to perform the * Given query string to perform the search on.
* search on.
*/ */
@IsDefined() @IsDefined()
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
@ApiProperty({ @ApiPropertyOptional({
description: 'query', description: 'Given query string to perform the search on',
example: 'Particle Accelerator' example: 'Particle Accelerator',
}) })
query: string; query: string;
/**
* Page number to display.
*/
@IsDefined()
@IsNotEmpty()
@IsInt()
@ApiProperty({
description: 'page',
example: 3,
})
page: number;
/** /**
* Limits the number of displayed elements. * Limits the number of displayed elements.
*/ */
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@ApiProperty({ @ApiPropertyOptional({
description: 'limit', description: 'Limits the number of displayed elements',
example: 10, example: 10,
}) })
limit: number; limit?: number;
/** /**
* Limits the number of displayed elements. * Offset from the start of the list of hits.
*/
@IsOptional()
@IsInt()
@ApiPropertyOptional({
description: 'Offset from the start of the list of hits',
example: 0,
})
offset?: number;
/**
* Indicates in which order elements need to be displayed.
*/ */
@IsOptional() @IsOptional()
@IsString() @IsString()
@ApiProperty({ @ApiPropertyOptional({
description: 'order', description: 'Indicates in which order elements need to be displayed',
example: 'asc', example: 'asc',
}) })
order: string; order?: string;
/** /**
* Constructs an object with provided parameters *
* @param query */
* @param page
* @param limit /**
* @param order * Constructs an object with provided parameters
*/ * @param query
constructor(query: string, page: number, limit: number, order: string) { * @param page
* @param limit
* @param order
*/
constructor(query: string, page: number, limit: number, order: string) {
this.query = query; this.query = query;
this.page = page;
this.limit = limit; this.limit = limit;
this.order = order; this.order = order;
} }
} }

View File

@ -11,4 +11,11 @@ export enum Order {
* Descending order * Descending order
*/ */
DESC = 'desc', DESC = 'desc',
}
export function toOrder(str: string): Order {
switch (str) {
case 'asc': return Order.ASC;
case 'desc': return Order.DESC;
}
} }

View File

@ -1,5 +1,4 @@
export * from './http-response.interface' export * from './http-response.interface'
export * from './page-meta.interface'
export * from './search-info.interface' export * from './search-info.interface'
export * from './elastic/es-query.interface' export * from './elastic/es-query.interface'
export * from './elastic/es-query-string.interface' export * from './elastic/es-query-string.interface'

View File

@ -1,36 +0,0 @@
import { Order } from "../enums/page-order.enum";
/**
* Structure of page metadata
*/
export interface PageMeta {
/**
* Total search results
*/
total: number;
/**
* Number of the page
*/
pagenum: number;
/**
* Order of the elements on the page
*/
order: Order;
/**
* Flag that indicates presence of the next page
*/
hasNext: boolean;
/**
* Flag that indicates presence of the previous page
*/
hasPrev: boolean;
/**
* Number of elements on the page
*/
pagesize: number;
}

View File

@ -6,10 +6,10 @@ import { EsQueryDto } from "../domain/dtos/elastic/es-query.dto";
import { RequestDto } from "../domain/dtos/request.dto"; import { RequestDto } from "../domain/dtos/request.dto";
import { SearchQueryDto } from "../domain/dtos/search-q.dto"; import { SearchQueryDto } from "../domain/dtos/search-q.dto";
import { EsTime } from "../domain/enums/es-time.enum"; import { EsTime } from "../domain/enums/es-time.enum";
import { Order } from "../domain/enums/page-order.enum"; import { Order, toOrder } from "../domain/enums/page-order.enum";
import { PageMeta } from "../domain/interfaces";
import { EsPit } from "../domain/interfaces/elastic/es-pit.interface"; import { EsPit } from "../domain/interfaces/elastic/es-pit.interface";
import { Cache } from 'cache-manager' import { Cache } from 'cache-manager'
import { PageMetaDto } from "../domain/dtos/page-meta.dto";
/** /**
* Pagination-implementing interceptor * Pagination-implementing interceptor
@ -45,8 +45,12 @@ export class PageInterceptor implements NestInterceptor {
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<PageDto>> { async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<PageDto>> {
const request: RequestDto = context.switchToHttp().getRequest<RequestDto>(); const request: RequestDto = context.switchToHttp().getRequest<RequestDto>();
const query: SearchQueryDto = request.query; const query: SearchQueryDto = request.query;
let reverse: boolean = false;
const offset = !query.offset ? 0 : query.offset;
const limit = !query.limit ? 10 : query.limit;
const order = !query.order ? Order.DESC : query.order;
// Contruct a body for querying Elasticsearch
request.es_query = new EsQueryDto(); request.es_query = new EsQueryDto();
request.es_query.query = { request.es_query.query = {
query_string: { query_string: {
@ -54,63 +58,33 @@ export class PageInterceptor implements NestInterceptor {
default_field: 'content', default_field: 'content',
} }
}; };
request.es_query.from = offset;
request.es_query.size = limit;
request.es_query.sort = [ request.es_query.sort = [
{ _score: { order: !query?.order ? Order.DESC : query.order } }, { "_score": { "order": order } },
{ _shard_doc: 'desc' } ];
];
const limit = !query?.limit ? 10 : query.limit; const prev_page = await this.cacheManager.get('prev_page');
if (prev_page) {
if (await this.cacheManager.get('prevPage')) { if (offset == prev_page[1] && limit == prev_page[2]) return prev_page[0];
if (query.page == (await this.cacheManager.get('_pagenum'))) return await this.cacheManager.get('prevPage');
request.es_query.pit = await this.cacheManager.get('_pit');
request.es_query.search_after = await this.cacheManager.get('_sa');
request.es_query.size = limit * Math.abs(query.page - (await this.cacheManager.get('_pagenum')));
if (query.page < (await this.cacheManager.get('_pagenum'))) {
request.es_query.sort = [{ _score: { order: Order.ASC } }];
request.es_query.size += limit - 1;
reverse = true;
}
} else {
request.es_query.pit = await this.getPIT(1);
request.es_query.size = limit * query.page;
} }
return next.handle().pipe( return next.handle().pipe(
switchMap(async (res) => { switchMap(async (res) => {
// Setting the page meta-data // Setting the page meta-data
let meta: PageMeta = { let meta: PageMetaDto = {
total: res.hits.total.value, total: res.hits.total.value,
pagenum: !query?.page ? 1 : +query.page, order: toOrder(order),
order: query?.order?.toUpperCase() === Order.ASC ? Order.ASC : Order.DESC,
pagesize: !query?.limit ? 10 : query.limit,
hasNext: undefined,
hasPrev: undefined,
}; };
meta.hasNext = meta.pagenum * meta.pagesize < meta.total ? true : false;
meta.hasPrev = meta.pagenum != 1 ? true : false;
// Saving the search info
await this.cacheManager.set('_pit', { id: res.pit_id, keep_alive: `1${EsTime.min}` })
await this.cacheManager.set('_sa', res.hits.hits[res.hits.hits.length - 1]?.sort);
await this.cacheManager.set('_pagenum', query.page);
// Check if the performed search is a backwards search // Check if the performed search is a backwards search
let data = res.hits.hits.slice(-meta.pagesize); let data = res.hits.hits;
if (reverse) {
this.cacheManager.set('_sa', data[0]?.sort);
data.reverse();
reverse = false;
}
// Omitting the redundant info and leaving only the document // Omitting the redundant info and leaving only the document
data = data.map((el) => el._source); data = data.map((el) => el._source);
// Cache and return the page // Cache and return the page
const page: PageDto = new PageDto(data, meta); const page: PageDto = new PageDto(data, meta);
await this.cacheManager.set('prevPage', page); await this.cacheManager.set('prev_page', [page, offset, limit]);
return page; return page;
}) })
); );
@ -121,19 +95,19 @@ export class PageInterceptor implements NestInterceptor {
* @param alive, amount of time in minutes (defaults to 1). If time unit is not specified - defaults to minutes. * @param alive, amount of time in minutes (defaults to 1). If time unit is not specified - defaults to minutes.
* @returns PIT object <EsPit> containing PIT ID and keep_alive value * @returns PIT object <EsPit> containing PIT ID and keep_alive value
*/ */
public async getPIT(alive: number, unit: EsTime = EsTime.min): Promise<EsPit> { public async getPIT(alive: number, unit: EsTime = EsTime.min): Promise<EsPit> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
this.httpService.post<EsPit>(`http://${this.ES_IP}:${this.ES_PORT}/papers/_pit?keep_alive=${alive+unit}`) this.httpService.post<EsPit>(`http://${this.ES_IP}:${this.ES_PORT}/papers/_pit?keep_alive=${alive+unit}`)
.pipe(take(1), map(axiosRes => axiosRes.data)) .pipe(take(1), map(axiosRes => axiosRes.data))
.subscribe((res: EsPit) => { .subscribe((res: EsPit) => {
res.keep_alive = alive + unit; res.keep_alive = alive + unit;
resolve(res); resolve(res);
}); });
} catch (error) { } catch (error) {
reject(error); reject(error);
} }
}); });
} }
/** /**
@ -141,20 +115,20 @@ export class PageInterceptor implements NestInterceptor {
* @param pitID, ID of the PIT, that would be deleted * @param pitID, ID of the PIT, that would be deleted
* @returns true/false, depending on the result of deletion of the PIT * @returns true/false, depending on the result of deletion of the PIT
*/ */
async deletePIT(pitID: string): Promise<boolean> { async deletePIT(pitID: string): Promise<boolean> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
this.httpService.delete(`http://${this.ES_IP}:${this.ES_PORT}/_pit`, { this.httpService.delete(`http://${this.ES_IP}:${this.ES_PORT}/_pit`, {
data: { id: pitID }, data: { id: pitID },
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) })
.pipe(take(1), map(axiosRes => axiosRes.data)) .pipe(take(1), map(axiosRes => axiosRes.data))
.subscribe((res) => { .subscribe((res) => {
resolve(res.succeeded); resolve(res.succeeded);
}); });
} catch (error) { } catch (error) {
reject(error); reject(error);
} }
}) })
} }
} }

View File

@ -205,20 +205,8 @@ describe('E2E Testing of /papers', () => {
expect(test.body.meta.total).toBeDefined(); expect(test.body.meta.total).toBeDefined();
expect(test.body.meta.total).toBe(2); expect(test.body.meta.total).toBe(2);
expect(test.body.meta.pagenum).toBeDefined();
expect(test.body.meta.pagenum).toBe(1);
expect(test.body.meta.order).toBeDefined(); expect(test.body.meta.order).toBeDefined();
expect(test.body.meta.order).toBe(Order.DESC); expect(test.body.meta.order).toBe(Order.DESC);
expect(test.body.meta.pagesize).toBeDefined();
expect(test.body.meta.pagesize).toBe(10);
expect(test.body.meta.hasNext).toBeDefined();
expect(test.body.meta.hasNext).toBe(false);
expect(test.body.meta.hasPrev).toBeDefined();
expect(test.body.meta.hasPrev).toBe(false);
}); });
afterAll(async () => { afterAll(async () => {

View File

@ -159,40 +159,7 @@ describe('Unit tests for PageInterceptor', () => {
}); });
}); });
}); });
}); });
it('Should reverse the search results', async () => {
execCtxMock.getRequest.mockReturnValueOnce({
query: {
page: 1,
order: 'desc',
limit: 3
}
});
await pageInter['cacheManager'].set('_pagenum', 3);
await pageInter['cacheManager'].set('prevPage', { set: "yes" });
callHandlerMock.handle.mockReturnValueOnce(
of({
hits: {
total: { value: 1 },
hits: [
{ sort: ['1', 'less relevant'], _source: '1' },
{ sort: ['2', 'average'], _source: '2' },
{ sort: ['3', 'most relevant'], _source: '3' }
]
}
})
);
pageInter.intercept(execCtxMock, callHandlerMock).then((res) => {
res.subscribe(async (page) => {
expect(await pageInter['cacheManager'].get('_sa')).toEqual(['1', 'less relevant']);
expect(page.data).toEqual(['3', '2', '1']);
});
});
});
}); });
describe('getPIT()', () => { describe('getPIT()', () => {