Skip to content

Commit

Permalink
feat: cursor based pagination in search (#5174)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew committed Oct 27, 2023
1 parent c9f9fc7 commit 6d17c3b
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 4 deletions.
13 changes: 12 additions & 1 deletion src/lib/features/feature-search/feature-search-controller.ts
Expand Up @@ -72,7 +72,15 @@ export default class FeatureSearchController extends Controller {
res: Response,
): Promise<void> {
if (this.config.flagResolver.isEnabled('featureSearchAPI')) {
const { query, projectId, type, tag, status } = req.query;
const {
query,
projectId,
type,
tag,
status,
cursor,
limit = 50,
} = req.query;
const userId = req.user.id;
const normalizedTag = tag
?.map((tag) => tag.split(':'))
Expand All @@ -84,13 +92,16 @@ export default class FeatureSearchController extends Controller {
tag.length === 2 &&
['enabled', 'disabled'].includes(tag[1]),
);
const normalizedLimit = limit > 0 && limit <= 50 ? limit : 50;
const features = await this.featureSearchService.search({
query,
projectId,
type,
userId,
tag: normalizedTag,
status: normalizedStatus,
cursor,
limit: normalizedLimit,
});
res.json({ features });
} else {
Expand Down
56 changes: 56 additions & 0 deletions src/lib/features/feature-search/feature.search.e2e.test.ts
Expand Up @@ -43,6 +43,22 @@ const searchFeatures = async (
.expect(expectedCode);
};

const searchFeaturesWithCursor = async (
{
query = '',
projectId = 'default',
cursor = '',
limit = 10,
}: FeatureSearchQueryParameters,
expectedCode = 200,
) => {
return app.request
.get(
`/api/admin/search/features?query=${query}&projectId=${projectId}&cursor=${cursor}&limit=${limit}`,
)
.expect(expectedCode);
};

const filterFeaturesByType = async (types: string[], expectedCode = 200) => {
const typeParams = types.map((type) => `type[]=${type}`).join('&');
return app.request
Expand Down Expand Up @@ -85,6 +101,46 @@ test('should search matching features by name', async () => {
});
});

test('should paginate with cursor', async () => {
await app.createFeature('my_feature_a');
await app.createFeature('my_feature_b');
await app.createFeature('my_feature_c');
await app.createFeature('my_feature_d');

const { body: firstPage } = await searchFeaturesWithCursor({
query: 'feature',
cursor: '',
limit: 2,
});
const nextCursor =
firstPage.features[firstPage.features.length - 1].createdAt;

expect(firstPage).toMatchObject({
features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }],
});

const { body: secondPage } = await searchFeaturesWithCursor({
query: 'feature',
cursor: nextCursor,
limit: 2,
});

expect(secondPage).toMatchObject({
features: [{ name: 'my_feature_c' }, { name: 'my_feature_d' }],
});
const lastCursor =
secondPage.features[secondPage.features.length - 1].createdAt;

const { body: lastPage } = await searchFeaturesWithCursor({
query: 'feature',
cursor: lastCursor,
limit: 2,
});
expect(lastPage).toMatchObject({
features: [],
});
});

test('should filter features by type', async () => {
await app.createFeature({ name: 'my_feature_a', type: 'release' });
await app.createFeature({ name: 'my_feature_b', type: 'experimental' });
Expand Down
27 changes: 24 additions & 3 deletions src/lib/features/feature-toggle/feature-toggle-strategies-store.ts
Expand Up @@ -26,6 +26,7 @@ import { IFeatureProjectUserParams } from './feature-toggle-controller';
import { Db } from '../../db/db';
import Raw = Knex.Raw;
import { IFeatureSearchParams } from './types/feature-toggle-strategies-store-type';
import { addMilliseconds, format, formatISO, parseISO } from 'date-fns';

const COLUMNS = [
'id',
Expand Down Expand Up @@ -523,6 +524,8 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
type,
tag,
status,
cursor,
limit,
}: IFeatureSearchParams): Promise<IFeatureOverview[]> {
let query = this.db('features');
if (projectId) {
Expand All @@ -540,9 +543,11 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
`%${queryString}%`,
]);

query = query
.whereILike('features.name', `%${queryString}%`)
.orWhereIn('features.name', tagQuery);
query = query.where((builder) => {
builder
.whereILike('features.name', `%${queryString}%`)
.orWhereIn('features.name', tagQuery);
});
}
if (tag && tag.length > 0) {
const tagQuery = this.db
Expand Down Expand Up @@ -571,6 +576,21 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
});
}

// workaround for imprecise timestamp that was including the cursor itself
const addMillisecond = (cursor: string) =>
format(
addMilliseconds(parseISO(cursor), 1),
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
);
if (cursor) {
query = query.where(
'features.created_at',
'>',
addMillisecond(cursor),
);
}
query = query.orderBy('features.created_at', 'asc').limit(limit);

query = query
.modify(FeatureToggleStore.filterByArchived, false)
.leftJoin(
Expand Down Expand Up @@ -656,6 +676,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {

query = query.select(selectColumns);
const rows = await query;

if (rows.length > 0) {
const overview = this.getFeatureOverviewData(getUniqueRows(rows));
return sortEnvironments(overview);
Expand Down
Expand Up @@ -28,6 +28,8 @@ export interface IFeatureSearchParams {
type?: string[];
tag?: string[][];
status?: string[][];
limit: number;
cursor?: string;
}

export interface IFeatureStrategiesStore
Expand Down
20 changes: 20 additions & 0 deletions src/lib/openapi/spec/feature-search-query-parameters.ts
Expand Up @@ -57,6 +57,26 @@ export const featureSearchQueryParameters = [
'The list of feature environment status to filter by. Feature environment has to specify a name and a status joined with a colon.',
in: 'query',
},
{
name: 'cursor',
schema: {
type: 'string',
example: '1',
},
description:
'The last feature id the client has seen. Used for cursor-based pagination.',
in: 'query',
},
{
name: 'limit',
schema: {
type: 'number',
example: 10,
},
description:
'The number of results to return in a page. By default it is set to 50',
in: 'query',
},
] as const;

export type FeatureSearchQueryParameters = Partial<
Expand Down

0 comments on commit 6d17c3b

Please sign in to comment.