Rework search paging based on cursors
Signed-off-by: Oliver Sand <oliver.sand@sda-se.com>
This commit is contained in:
@@ -17,7 +17,12 @@
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import lunr from 'lunr';
|
||||
import { SearchEngine } from '@backstage/search-common';
|
||||
import { ConcreteLunrQuery, LunrSearchEngine } from './LunrSearchEngine';
|
||||
import {
|
||||
ConcreteLunrQuery,
|
||||
LunrSearchEngine,
|
||||
decodePageCursor,
|
||||
encodePageCursor,
|
||||
} from './LunrSearchEngine';
|
||||
|
||||
/**
|
||||
* Just used to test the default translator shipped with LunrSearchEngine.
|
||||
@@ -48,12 +53,14 @@ describe('LunrSearchEngine', () => {
|
||||
await testLunrSearchEngine.query({
|
||||
term: 'testTerm',
|
||||
filters: {},
|
||||
pageCursor: 'MQ==',
|
||||
});
|
||||
|
||||
// Then: the translator is invoked with expected args.
|
||||
expect(translatorSpy).toHaveBeenCalledWith({
|
||||
term: 'testTerm',
|
||||
filters: {},
|
||||
pageCursor: 'MQ==',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,15 +73,12 @@ describe('LunrSearchEngine', () => {
|
||||
const actualTranslatedQuery = translatorUnderTest({
|
||||
term: 'testTerm',
|
||||
filters: {},
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
}) as ConcreteLunrQuery;
|
||||
|
||||
expect(actualTranslatedQuery).toMatchObject({
|
||||
documentTypes: undefined,
|
||||
lunrQueryBuilder: expect.any(Function),
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
pageSize: 25,
|
||||
});
|
||||
|
||||
const query: jest.Mocked<lunr.Query> = {
|
||||
@@ -115,52 +119,7 @@ describe('LunrSearchEngine', () => {
|
||||
expect(actualTranslatedQuery).toMatchObject({
|
||||
documentTypes: undefined,
|
||||
lunrQueryBuilder: expect.any(Function),
|
||||
offset: 0,
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
const query: jest.Mocked<lunr.Query> = {
|
||||
allFields: [],
|
||||
clauses: [],
|
||||
term: jest.fn(),
|
||||
clause: jest.fn(),
|
||||
};
|
||||
|
||||
actualTranslatedQuery.lunrQueryBuilder.bind(query)(query);
|
||||
|
||||
expect(query.term).toBeCalledWith(lunr.tokenizer('testTerm'), {
|
||||
boost: 100,
|
||||
usePipeline: true,
|
||||
});
|
||||
expect(query.term).toBeCalledWith(lunr.tokenizer('testTerm'), {
|
||||
boost: 10,
|
||||
usePipeline: false,
|
||||
wildcard: lunr.Query.wildcard.TRAILING,
|
||||
});
|
||||
expect(query.term).toBeCalledWith(lunr.tokenizer('testTerm'), {
|
||||
boost: 1,
|
||||
usePipeline: false,
|
||||
editDistance: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should have maximum limit', async () => {
|
||||
const inspectableSearchEngine = new LunrSearchEngineForTranslatorTests({
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
const translatorUnderTest = inspectableSearchEngine.getTranslator();
|
||||
|
||||
const actualTranslatedQuery = translatorUnderTest({
|
||||
term: 'testTerm',
|
||||
offset: 0,
|
||||
limit: 1000,
|
||||
}) as ConcreteLunrQuery;
|
||||
|
||||
expect(actualTranslatedQuery).toMatchObject({
|
||||
documentTypes: undefined,
|
||||
lunrQueryBuilder: expect.any(Function),
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
pageSize: 25,
|
||||
});
|
||||
|
||||
const query: jest.Mocked<lunr.Query> = {
|
||||
@@ -328,7 +287,10 @@ describe('LunrSearchEngine', () => {
|
||||
});
|
||||
|
||||
// Should return 0 results as nothing is indexed here
|
||||
expect(mockedSearchResult).toMatchObject({ results: [], totalCount: 0 });
|
||||
expect(mockedSearchResult).toMatchObject({
|
||||
results: [],
|
||||
nextPageCursor: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should perform search query and return 0 results on no match', async () => {
|
||||
@@ -350,7 +312,10 @@ describe('LunrSearchEngine', () => {
|
||||
});
|
||||
|
||||
// Should return 0 results as we are mocking the indexing of 1 document but with no match on the fields
|
||||
expect(mockedSearchResult).toMatchObject({ results: [], totalCount: 0 });
|
||||
expect(mockedSearchResult).toMatchObject({
|
||||
results: [],
|
||||
nextPageCursor: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should perform search query and return all results on empty term', async () => {
|
||||
@@ -382,7 +347,7 @@ describe('LunrSearchEngine', () => {
|
||||
type: 'test-index',
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
nextPageCursor: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -414,7 +379,7 @@ describe('LunrSearchEngine', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
nextPageCursor: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -446,7 +411,7 @@ describe('LunrSearchEngine', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
nextPageCursor: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -479,7 +444,7 @@ describe('LunrSearchEngine', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
nextPageCursor: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -512,7 +477,7 @@ describe('LunrSearchEngine', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
nextPageCursor: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -545,7 +510,7 @@ describe('LunrSearchEngine', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
nextPageCursor: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -585,7 +550,7 @@ describe('LunrSearchEngine', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
nextPageCursor: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -627,7 +592,7 @@ describe('LunrSearchEngine', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
nextPageCursor: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -667,7 +632,7 @@ describe('LunrSearchEngine', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
nextPageCursor: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -725,9 +690,75 @@ describe('LunrSearchEngine', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
totalCount: 2,
|
||||
nextPageCursor: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return next page cursor if results exceed page size', async () => {
|
||||
const mockDocuments = Array(30)
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: `test/location/${i}`,
|
||||
}));
|
||||
|
||||
await testLunrSearchEngine.index('test-index', mockDocuments);
|
||||
|
||||
const mockedSearchResult = await testLunrSearchEngine.query({
|
||||
term: 'testTitle',
|
||||
types: ['test-index'],
|
||||
});
|
||||
|
||||
expect(mockedSearchResult).toMatchObject({
|
||||
results: Array(25)
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
document: {
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: `test/location/${i}`,
|
||||
},
|
||||
type: 'test-index',
|
||||
})),
|
||||
nextPageCursor: 'MQ==',
|
||||
previousPageCursor: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should return previous page cursor if on another page', async () => {
|
||||
const mockDocuments = Array(30)
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: `test/location/${i}`,
|
||||
}));
|
||||
|
||||
await testLunrSearchEngine.index('test-index', mockDocuments);
|
||||
|
||||
const mockedSearchResult = await testLunrSearchEngine.query({
|
||||
term: 'testTitle',
|
||||
types: ['test-index'],
|
||||
pageCursor: 'MQ==',
|
||||
});
|
||||
|
||||
expect(mockedSearchResult).toMatchObject({
|
||||
results: Array(30)
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
document: {
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: `test/location/${i}`,
|
||||
},
|
||||
type: 'test-index',
|
||||
}))
|
||||
.slice(25),
|
||||
nextPageCursor: undefined,
|
||||
previousPageCursor: 'MA==',
|
||||
});
|
||||
});
|
||||
|
||||
describe('index', () => {
|
||||
@@ -750,3 +781,19 @@ describe('LunrSearchEngine', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodePageCursor', () => {
|
||||
test('should decode page', () => {
|
||||
expect(decodePageCursor('MQ==')).toEqual({ page: 1 });
|
||||
});
|
||||
|
||||
test('should fallback to first page if empty', () => {
|
||||
expect(decodePageCursor()).toEqual({ page: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodePageCursor', () => {
|
||||
test('should encode page', () => {
|
||||
expect(encodePageCursor({ page: 1 })).toEqual('MQ==');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,8 +27,7 @@ import { Logger } from 'winston';
|
||||
export type ConcreteLunrQuery = {
|
||||
lunrQueryBuilder: lunr.Index.QueryBuilder;
|
||||
documentTypes?: string[];
|
||||
offset: number;
|
||||
limit: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
type LunrResultEnvelope = {
|
||||
@@ -52,9 +51,9 @@ export class LunrSearchEngine implements SearchEngine {
|
||||
term,
|
||||
filters,
|
||||
types,
|
||||
offset,
|
||||
limit,
|
||||
}: SearchQuery): ConcreteLunrQuery => {
|
||||
const pageSize = 25;
|
||||
|
||||
return {
|
||||
lunrQueryBuilder: q => {
|
||||
const termToken = lunr.tokenizer(term);
|
||||
@@ -111,8 +110,7 @@ export class LunrSearchEngine implements SearchEngine {
|
||||
}
|
||||
},
|
||||
documentTypes: types,
|
||||
offset: offset ?? 0,
|
||||
limit: Math.min(limit ?? 25, 100),
|
||||
pageSize,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -147,7 +145,7 @@ export class LunrSearchEngine implements SearchEngine {
|
||||
}
|
||||
|
||||
async query(query: SearchQuery): Promise<SearchResultSet> {
|
||||
const { lunrQueryBuilder, documentTypes, offset, limit } = this.translator(
|
||||
const { lunrQueryBuilder, documentTypes, pageSize } = this.translator(
|
||||
query,
|
||||
) as ConcreteLunrQuery;
|
||||
|
||||
@@ -183,14 +181,41 @@ export class LunrSearchEngine implements SearchEngine {
|
||||
return doc2.result.score - doc1.result.score;
|
||||
});
|
||||
|
||||
// Perform paging
|
||||
const { page } = decodePageCursor(query.pageCursor);
|
||||
const offset = page * pageSize;
|
||||
const hasPreviousPage = page > 0;
|
||||
const hasNextPage = results.length > offset + pageSize;
|
||||
const nextPageCursor = hasNextPage
|
||||
? encodePageCursor({ page: page + 1 })
|
||||
: undefined;
|
||||
const previousPageCursor = hasPreviousPage
|
||||
? encodePageCursor({ page: page - 1 })
|
||||
: undefined;
|
||||
|
||||
// Translate results into SearchResultSet
|
||||
const realResultSet: SearchResultSet = {
|
||||
results: results.slice(offset, offset + limit).map(d => {
|
||||
results: results.slice(offset, offset + pageSize).map(d => {
|
||||
return { type: d.type, document: this.docStore[d.result.ref] };
|
||||
}),
|
||||
totalCount: results.length,
|
||||
nextPageCursor,
|
||||
previousPageCursor,
|
||||
};
|
||||
|
||||
return realResultSet;
|
||||
}
|
||||
}
|
||||
|
||||
export function decodePageCursor(pageCursor?: string): { page: number } {
|
||||
if (!pageCursor) {
|
||||
return { page: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
page: Number(Buffer.from(pageCursor, 'base64').toString('utf-8')),
|
||||
};
|
||||
}
|
||||
|
||||
export function encodePageCursor({ page }: { page: number }): string {
|
||||
return Buffer.from(`${page}`, 'utf-8').toString('base64');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user