1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-31 13:47:02 +02:00

feat: introduce offset based search instead of cursor (#5274)

This commit is contained in:
Jaanus Sellin 2023-11-08 11:12:42 +02:00 committed by GitHub
parent 06d62278dc
commit 4bacd3e055
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 94 additions and 176 deletions

View File

@ -35,37 +35,37 @@ const StyledContentContainer = styled(Box)(() => ({
minWidth: 0, minWidth: 0,
})); }));
const InfiniteProjectOverview = () => { const PAGE_LIMIT = 25;
const PaginatedProjectOverview = () => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const { project, loading: projectLoading } = useProject(projectId, { const { project, loading: projectLoading } = useProject(projectId, {
refreshInterval, refreshInterval,
}); });
const [prevCursors, setPrevCursors] = useState<string[]>([]); const [currentOffset, setCurrentOffset] = useState(0);
const [currentCursor, setCurrentCursor] = useState('');
const { const {
features: searchFeatures, features: searchFeatures,
nextCursor,
total, total,
refetch, refetch,
loading, loading,
} = useFeatureSearch(currentCursor, projectId, { refreshInterval }); } = useFeatureSearch(currentOffset, PAGE_LIMIT, projectId, {
refreshInterval,
});
const { members, features, health, description, environments, stats } = const { members, features, health, description, environments, stats } =
project; project;
const fetchNextPage = () => { const fetchNextPage = () => {
if (!loading && nextCursor !== currentCursor && nextCursor !== '') { if (!loading) {
setPrevCursors([...prevCursors, currentCursor]); setCurrentOffset(Math.min(total, currentOffset + PAGE_LIMIT));
setCurrentCursor(nextCursor);
} }
}; };
const fetchPrevPage = () => { const fetchPrevPage = () => {
const prevCursor = prevCursors.pop(); setCurrentOffset(Math.max(0, currentOffset - PAGE_LIMIT));
if (prevCursor) {
setCurrentCursor(prevCursor);
}
setPrevCursors([...prevCursors]);
}; };
const hasPreviousPage = currentOffset > 0;
const hasNextPage = currentOffset + PAGE_LIMIT < total;
return ( return (
<StyledContainer> <StyledContainer>
<ProjectInfo <ProjectInfo
@ -91,10 +91,14 @@ const InfiniteProjectOverview = () => {
onChange={refetch} onChange={refetch}
total={total} total={total}
/> />
{prevCursors.length > 0 ? ( <ConditionallyRender
<Box onClick={fetchPrevPage}>Prev</Box> condition={hasPreviousPage}
) : null} show={<Box onClick={fetchPrevPage}>Prev</Box>}
{nextCursor && <Box onClick={fetchNextPage}>Next</Box>} />
<ConditionallyRender
condition={hasNextPage}
show={<Box onClick={fetchNextPage}>Next</Box>}
/>
</StyledProjectToggles> </StyledProjectToggles>
</StyledContentContainer> </StyledContentContainer>
</StyledContainer> </StyledContainer>
@ -118,7 +122,7 @@ const ProjectOverview = () => {
setLastViewed(projectId); setLastViewed(projectId);
}, [projectId, setLastViewed]); }, [projectId, setLastViewed]);
if (featureSearchFrontend) return <InfiniteProjectOverview />; if (featureSearchFrontend) return <PaginatedProjectOverview />;
return ( return (
<StyledContainer> <StyledContainer>

View File

@ -6,14 +6,12 @@ import handleErrorResponses from '../httpErrorResponseHandler';
type IFeatureSearchResponse = { type IFeatureSearchResponse = {
features: IFeatureToggleListItem[]; features: IFeatureToggleListItem[];
nextCursor: string;
total: number; total: number;
}; };
interface IUseFeatureSearchOutput { interface IUseFeatureSearchOutput {
features: IFeatureToggleListItem[]; features: IFeatureToggleListItem[];
total: number; total: number;
nextCursor: string;
loading: boolean; loading: boolean;
error: string; error: string;
refetch: () => void; refetch: () => void;
@ -22,19 +20,18 @@ interface IUseFeatureSearchOutput {
const fallbackData: { const fallbackData: {
features: IFeatureToggleListItem[]; features: IFeatureToggleListItem[];
total: number; total: number;
nextCursor: string;
} = { } = {
features: [], features: [],
total: 0, total: 0,
nextCursor: '',
}; };
export const useFeatureSearch = ( export const useFeatureSearch = (
cursor: string, offset: number,
limit: number,
projectId = '', projectId = '',
options: SWRConfiguration = {}, options: SWRConfiguration = {},
): IUseFeatureSearchOutput => { ): IUseFeatureSearchOutput => {
const { KEY, fetcher } = getFeatureSearchFetcher(projectId, cursor); const { KEY, fetcher } = getFeatureSearchFetcher(projectId, offset, limit);
const { data, error, mutate } = useSWR<IFeatureSearchResponse>( const { data, error, mutate } = useSWR<IFeatureSearchResponse>(
KEY, KEY,
fetcher, fetcher,
@ -54,15 +51,12 @@ export const useFeatureSearch = (
}; };
}; };
// temporary experiment const getFeatureSearchFetcher = (
const getQueryParam = (queryParam: string, path: string | null) => { projectId: string,
const url = new URL(path || '', 'https://getunleash.io'); offset: number,
const params = new URLSearchParams(url.search); limit: number,
return params.get(queryParam) || ''; ) => {
}; const KEY = `api/admin/search/features?projectId=${projectId}&offset=${offset}&limit=${limit}`;
const getFeatureSearchFetcher = (projectId: string, cursor: string) => {
const KEY = `api/admin/search/features?projectId=${projectId}&cursor=${cursor}&limit=25`;
const fetcher = () => { const fetcher = () => {
const path = formatApiPath(KEY); const path = formatApiPath(KEY);
@ -70,15 +64,7 @@ const getFeatureSearchFetcher = (projectId: string, cursor: string) => {
method: 'GET', method: 'GET',
}) })
.then(handleErrorResponses('Feature search')) .then(handleErrorResponses('Feature search'))
.then(async (res) => { .then((res) => res.json());
const json = await res.json();
// TODO: try using Link as key
const nextCursor = getQueryParam(
'cursor',
res.headers.get('link'),
);
return { ...json, nextCursor };
});
}; };
return { return {

View File

@ -1,9 +1,8 @@
import { Response, Request } from 'express'; import { Response } from 'express';
import Controller from '../../routes/controller'; import Controller from '../../routes/controller';
import { FeatureSearchService, OpenApiService } from '../../services'; import { FeatureSearchService, OpenApiService } from '../../services';
import { import {
IFlagResolver, IFlagResolver,
ITag,
IUnleashConfig, IUnleashConfig,
IUnleashServices, IUnleashServices,
NONE, NONE,
@ -16,7 +15,6 @@ import {
FeatureSearchQueryParameters, FeatureSearchQueryParameters,
featureSearchQueryParameters, featureSearchQueryParameters,
} from '../../openapi/spec/feature-search-query-parameters'; } from '../../openapi/spec/feature-search-query-parameters';
import { nextLink } from './next-link';
const PATH = '/features'; const PATH = '/features';
@ -79,7 +77,7 @@ export default class FeatureSearchController extends Controller {
type, type,
tag, tag,
status, status,
cursor, offset,
limit = '50', limit = '50',
sortOrder, sortOrder,
sortBy, sortBy,
@ -97,24 +95,23 @@ export default class FeatureSearchController extends Controller {
); );
const normalizedLimit = const normalizedLimit =
Number(limit) > 0 && Number(limit) <= 50 ? Number(limit) : 50; Number(limit) > 0 && Number(limit) <= 50 ? Number(limit) : 50;
const normalizedOffset = Number(offset) > 0 ? Number(limit) : 0;
const normalizedSortBy: string = sortBy ? sortBy : 'createdAt'; const normalizedSortBy: string = sortBy ? sortBy : 'createdAt';
const normalizedSortOrder = const normalizedSortOrder =
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc'; sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
const { features, nextCursor, total } = const { features, total } = await this.featureSearchService.search({
await this.featureSearchService.search({ query,
query, projectId,
projectId, type,
type, userId,
userId, tag: normalizedTag,
tag: normalizedTag, status: normalizedStatus,
status: normalizedStatus, offset: normalizedOffset,
cursor, limit: normalizedLimit,
limit: normalizedLimit, sortBy: normalizedSortBy,
sortBy: normalizedSortBy, sortOrder: normalizedSortOrder,
sortOrder: normalizedSortOrder, });
});
res.header('Link', nextLink(req, nextCursor));
res.json({ features, total }); res.json({ features, total });
} else { } else {
throw new InvalidOperationError( throw new InvalidOperationError(

View File

@ -25,21 +25,11 @@ export class FeatureSearchService {
const { features, total } = const { features, total } =
await this.featureStrategiesStore.searchFeatures({ await this.featureStrategiesStore.searchFeatures({
...params, ...params,
limit: params.limit + 1, limit: params.limit,
}); });
const nextCursor =
features.length > params.limit
? features[features.length - 1].createdAt.toJSON()
: undefined;
// do not return the items with the next cursor
return { return {
features: features,
features.length > params.limit
? features.slice(0, -1)
: features,
nextCursor,
total, total,
}; };
} }

View File

@ -58,26 +58,22 @@ const sortFeatures = async (
.expect(expectedCode); .expect(expectedCode);
}; };
const searchFeaturesWithCursor = async ( const searchFeaturesWithOffset = async (
{ {
query = '', query = '',
projectId = 'default', projectId = 'default',
cursor = '', offset = '0',
limit = '10', limit = '10',
}: FeatureSearchQueryParameters, }: FeatureSearchQueryParameters,
expectedCode = 200, expectedCode = 200,
) => { ) => {
return app.request return app.request
.get( .get(
`/api/admin/search/features?query=${query}&projectId=${projectId}&cursor=${cursor}&limit=${limit}`, `/api/admin/search/features?query=${query}&projectId=${projectId}&offset=${offset}&limit=${limit}`,
) )
.expect(expectedCode); .expect(expectedCode);
}; };
const getPage = async (url: string, expectedCode = 200) => {
return app.request.get(url).expect(expectedCode);
};
const filterFeaturesByType = async (types: string[], expectedCode = 200) => { const filterFeaturesByType = async (types: string[], expectedCode = 200) => {
const typeParams = types.map((type) => `type[]=${type}`).join('&'); const typeParams = types.map((type) => `type[]=${type}`).join('&');
return app.request return app.request
@ -121,16 +117,16 @@ test('should search matching features by name', async () => {
}); });
}); });
test('should paginate with cursor', async () => { test('should paginate with offset', async () => {
await app.createFeature('my_feature_a'); await app.createFeature('my_feature_a');
await app.createFeature('my_feature_b'); await app.createFeature('my_feature_b');
await app.createFeature('my_feature_c'); await app.createFeature('my_feature_c');
await app.createFeature('my_feature_d'); await app.createFeature('my_feature_d');
const { body: firstPage, headers: firstHeaders } = const { body: firstPage, headers: firstHeaders } =
await searchFeaturesWithCursor({ await searchFeaturesWithOffset({
query: 'feature', query: 'feature',
cursor: '', offset: '0',
limit: '2', limit: '2',
}); });
@ -139,16 +135,17 @@ test('should paginate with cursor', async () => {
total: 4, total: 4,
}); });
const { body: secondPage, headers: secondHeaders } = await getPage( const { body: secondPage, headers: secondHeaders } =
firstHeaders.link, await searchFeaturesWithOffset({
); query: 'feature',
offset: '2',
limit: '2',
});
expect(secondPage).toMatchObject({ expect(secondPage).toMatchObject({
features: [{ name: 'my_feature_c' }, { name: 'my_feature_d' }], features: [{ name: 'my_feature_c' }, { name: 'my_feature_d' }],
total: 4, total: 4,
}); });
expect(secondHeaders.link).toBe('');
}); });
test('should filter features by type', async () => { test('should filter features by type', async () => {

View File

@ -1,55 +0,0 @@
import { nextLink } from './next-link';
import { Request } from 'express';
describe('nextLink', () => {
it('should generate the correct next link with a cursor', () => {
const req = {
baseUrl: '/api/events',
path: '/',
query: { page: '2', limit: '10' },
} as Pick<Request, 'baseUrl' | 'path' | 'query'>;
const cursor = 'abc123';
const result = nextLink(req, cursor);
expect(result).toBe('/api/events/?page=2&limit=10&cursor=abc123');
});
it('should generate the correct next link without a cursor', () => {
const req = {
baseUrl: '/api/events',
path: '/',
query: { page: '2', limit: '10' },
} as Pick<Request, 'baseUrl' | 'path' | 'query'>;
const result = nextLink(req);
expect(result).toBe('');
});
it('should exclude existing cursor from query parameters', () => {
const req = {
baseUrl: '/api/events',
path: '/',
query: { page: '2', limit: '10', cursor: 'oldCursor' },
} as Pick<Request, 'baseUrl' | 'path' | 'query'>;
const cursor = 'newCursor';
const result = nextLink(req, cursor);
expect(result).toBe('/api/events/?page=2&limit=10&cursor=newCursor');
});
it('should handle empty query parameters correctly', () => {
const req = {
baseUrl: '/api/events',
path: '/',
query: {},
} as Pick<Request, 'baseUrl' | 'path' | 'query'>;
const cursor = 'abc123';
const result = nextLink(req, cursor);
expect(result).toBe('/api/events/?cursor=abc123');
});
});

View File

@ -1,18 +0,0 @@
import { Request } from 'express';
export function nextLink(
req: Pick<Request, 'baseUrl' | 'path' | 'query'>,
cursor?: string,
): string {
if (!cursor) {
return '';
}
const url = `${req.baseUrl}${req.path}?`;
const params = new URLSearchParams(req.query as Record<string, string>);
params.set('cursor', cursor);
return `${url}${params.toString()}`;
}

View File

@ -253,7 +253,10 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
environment: string, environment: string,
): Promise<void> { ): Promise<void> {
await this.db('feature_strategies') await this.db('feature_strategies')
.where({ feature_name: featureName, environment }) .where({
feature_name: featureName,
environment,
})
.del(); .del();
} }
@ -296,8 +299,14 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
environment, environment,
}) })
.orderBy([ .orderBy([
{ column: 'sort_order', order: 'asc' }, {
{ column: 'created_at', order: 'asc' }, column: 'sort_order',
order: 'asc',
},
{
column: 'created_at',
order: 'asc',
},
]); ]);
stopTimer(); stopTimer();
return rows.map(mapRow); return rows.map(mapRow);
@ -530,7 +539,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
type, type,
tag, tag,
status, status,
cursor, offset,
limit, limit,
sortOrder, sortOrder,
sortBy, sortBy,
@ -680,10 +689,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
]; ];
} }
if (cursor) {
query = query.where('features.created_at', '>=', cursor);
}
const sortByMapping = { const sortByMapping = {
name: 'feature_name', name: 'feature_name',
type: 'type', type: 'type',
@ -709,15 +714,24 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
.countDistinct({ total: 'features.name' }) .countDistinct({ total: 'features.name' })
.first(); .first();
query = query.select(selectColumns).limit(limit * environmentCount); query = query
.select(selectColumns)
.limit(limit * environmentCount)
.offset(offset * environmentCount);
const rows = await query; const rows = await query;
if (rows.length > 0) { if (rows.length > 0) {
const overview = this.getFeatureOverviewData(getUniqueRows(rows)); const overview = this.getFeatureOverviewData(getUniqueRows(rows));
const features = sortEnvironments(overview); const features = sortEnvironments(overview);
return { features, total: Number(total?.total) || 0 }; return {
features,
total: Number(total?.total) || 0,
};
} }
return { features: [], total: 0 }; return {
features: [],
total: 0,
};
} }
async getFeatureOverview({ async getFeatureOverview({
@ -915,7 +929,10 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
environment: String, environment: String,
): Promise<void> { ): Promise<void> {
await this.db(T.featureStrategies) await this.db(T.featureStrategies)
.where({ project_name: projectId, environment }) .where({
project_name: projectId,
environment,
})
.del(); .del();
} }

View File

@ -28,8 +28,8 @@ export interface IFeatureSearchParams {
type?: string[]; type?: string[];
tag?: string[][]; tag?: string[][];
status?: string[][]; status?: string[][];
offset: number;
limit: number; limit: number;
cursor?: string;
sortBy: string; sortBy: string;
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
} }

View File

@ -58,13 +58,13 @@ export const featureSearchQueryParameters = [
in: 'query', in: 'query',
}, },
{ {
name: 'cursor', name: 'offset',
schema: { schema: {
type: 'string', type: 'string',
example: '2023-10-31T09:21:04.056Z', example: '50',
}, },
description: description:
'The next feature created at date the client has not seen. Used for cursor-based pagination. Empty if starting from the beginning.', 'The number of features to skip when returning a page. By default it is set to 0.',
in: 'query', in: 'query',
}, },
{ {