1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: project applications server side paging and sorting and filtering (#6236)

Uses exactly same pattern as search-store. Nothing too crazy here.
Most code is in tests.
This commit is contained in:
Jaanus Sellin 2024-02-14 13:03:44 +02:00 committed by GitHub
parent 6a8f903bcf
commit 3d77825493
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 435 additions and 125 deletions

View File

@ -7,7 +7,6 @@ import {
LineChart, LineChart,
NotEnoughData, NotEnoughData,
} from '../LineChart/LineChart'; } from '../LineChart/LineChart';
import { type ScriptableContext } from 'chart.js';
interface IUsersChartProps { interface IUsersChartProps {
userTrends: ExecutiveSummarySchema['userTrends']; userTrends: ExecutiveSummarySchema['userTrends'];

View File

@ -16,6 +16,7 @@ import {
IFeatureSearchParams, IFeatureSearchParams,
IQueryParam, IQueryParam,
} from '../feature-toggle/types/feature-toggle-strategies-store-type'; } from '../feature-toggle/types/feature-toggle-strategies-store-type';
import { applyGenericQueryParams, applySearchFilters } from './search-utils';
const sortEnvironments = (overview: IFeatureOverview[]) => { const sortEnvironments = (overview: IFeatureOverview[]) => {
return overview.map((data: IFeatureOverview) => ({ return overview.map((data: IFeatureOverview) => ({
@ -87,28 +88,10 @@ class FeatureSearchStore implements IFeatureSearchStore {
query.from('features'); query.from('features');
applyQueryParams(query, queryParams); applyQueryParams(query, queryParams);
applySearchFilters(query, searchParams, [
const hasSearchParams = searchParams?.length; 'features.name',
if (hasSearchParams) { 'features.description',
const sqlParameters = searchParams.map( ]);
(item) => `%${item}%`,
);
const sqlQueryParameters = sqlParameters
.map(() => '?')
.join(',');
query.where((builder) => {
builder
.orWhereRaw(
`(??) ILIKE ANY (ARRAY[${sqlQueryParameters}])`,
['features.name', ...sqlParameters],
)
.orWhereRaw(
`(??) ILIKE ANY (ARRAY[${sqlQueryParameters}])`,
['features.description', ...sqlParameters],
);
});
}
if (type) { if (type) {
query.whereIn('features.type', type); query.whereIn('features.type', type);
@ -282,7 +265,6 @@ class FeatureSearchStore implements IFeatureSearchStore {
) )
.joinRaw('CROSS JOIN total_features') .joinRaw('CROSS JOIN total_features')
.whereBetween('final_rank', [offset + 1, offset + limit]); .whereBetween('final_rank', [offset + 1, offset + limit]);
const rows = await finalQuery; const rows = await finalQuery;
stopTimer(); stopTimer();
if (rows.length > 0) { if (rows.length > 0) {
@ -417,30 +399,6 @@ const applyQueryParams = (
); );
}; };
const applyGenericQueryParams = (
query: Knex.QueryBuilder,
queryParams: IQueryParam[],
): void => {
queryParams.forEach((param) => {
switch (param.operator) {
case 'IS':
case 'IS_ANY_OF':
query.whereIn(param.field, param.values);
break;
case 'IS_NOT':
case 'IS_NONE_OF':
query.whereNotIn(param.field, param.values);
break;
case 'IS_BEFORE':
query.where(param.field, '<', param.values[0]);
break;
case 'IS_ON_OR_AFTER':
query.where(param.field, '>=', param.values[0]);
break;
}
});
};
const applyMultiQueryParams = ( const applyMultiQueryParams = (
query: Knex.QueryBuilder, query: Knex.QueryBuilder,
queryParams: IQueryParam[], queryParams: IQueryParam[],

View File

@ -0,0 +1,47 @@
import { Knex } from 'knex';
import { IQueryParam } from '../feature-toggle/types/feature-toggle-strategies-store-type';
export const applySearchFilters = (
qb: Knex.QueryBuilder,
searchParams: string[] | undefined,
columns: string[],
): void => {
const hasSearchParams = searchParams?.length;
if (hasSearchParams) {
const sqlParameters = searchParams.map((item) => `%${item}%`);
const sqlQueryParameters = sqlParameters.map(() => '?').join(',');
qb.where((builder) => {
columns.forEach((column) => {
builder.orWhereRaw(
`(${column}) ILIKE ANY (ARRAY[${sqlQueryParameters}])`,
sqlParameters,
);
});
});
}
};
export const applyGenericQueryParams = (
query: Knex.QueryBuilder,
queryParams: IQueryParam[],
): void => {
queryParams.forEach((param) => {
switch (param.operator) {
case 'IS':
case 'IS_ANY_OF':
query.whereIn(param.field, param.values);
break;
case 'IS_NOT':
case 'IS_NONE_OF':
query.whereNotIn(param.field, param.values);
break;
case 'IS_BEFORE':
query.where(param.field, '<', param.values[0]);
break;
case 'IS_ON_OR_AFTER':
query.where(param.field, '>=', param.values[0]);
break;
}
});
};

View File

@ -89,19 +89,22 @@ test('should return applications', async () => {
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200); .expect(200);
expect(body).toMatchObject([ expect(body).toMatchObject({
{ applications: [
environments: ['default'], {
instances: ['instanceId'], environments: ['default'],
name: 'appName', instances: ['instanceId'],
sdks: [ name: 'appName',
{ sdks: [
name: 'unleash-client-test', {
versions: ['1.2'], name: 'unleash-client-test',
}, versions: ['1.2'],
], },
}, ],
]); },
],
total: 1,
});
}); });
test('should return applications if sdk was not in database', async () => { test('should return applications if sdk was not in database', async () => {
@ -128,14 +131,17 @@ test('should return applications if sdk was not in database', async () => {
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200); .expect(200);
expect(body).toMatchObject([ expect(body).toMatchObject({
{ applications: [
environments: ['default'], {
instances: ['instanceId'], environments: ['default'],
name: 'appName', instances: ['instanceId'],
sdks: [], name: 'appName',
}, sdks: [],
]); },
],
total: 1,
});
}); });
test('should return application without version if sdk has just name', async () => { test('should return application without version if sdk has just name', async () => {
@ -163,17 +169,222 @@ test('should return application without version if sdk has just name', async ()
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200); .expect(200);
expect(body).toMatchObject([ expect(body).toMatchObject({
{ applications: [
environments: ['default'], {
instances: ['instanceId'], environments: ['default'],
name: 'appName', instances: ['instanceId'],
sdks: [ name: 'appName',
{ sdks: [
name: 'unleash-client-test', {
versions: [], name: 'unleash-client-test',
}, versions: [],
], },
}, ],
]); },
],
total: 1,
});
});
test('should sort by appName descending', async () => {
await app.createFeature('toggle-name-1');
await app.request.post('/api/client/register').send({
appName: metrics.appName,
instanceId: metrics.instanceId,
strategies: ['default'],
sdkVersion: 'unleash-client-test',
started: Date.now(),
interval: 10,
});
const secondApp = 'second-app';
await app.request.post('/api/client/register').send({
appName: secondApp,
instanceId: metrics.instanceId,
strategies: ['default'],
sdkVersion: 'unleash-client-test',
started: Date.now(),
interval: 10,
});
await app.services.clientInstanceService.bulkAdd();
await app.request
.post('/api/client/metrics')
.set('Authorization', defaultToken.secret)
.send(metrics)
.expect(202);
await app.request
.post('/api/client/metrics')
.set('Authorization', defaultToken.secret)
.send({
...metrics,
appName: secondApp,
})
.expect(202);
await app.services.clientMetricsServiceV2.bulkAdd();
const { body } = await app.request
.get('/api/admin/projects/default/applications?sortOrder=desc')
.expect('Content-Type', /json/)
.expect(200);
expect(body).toMatchObject({
applications: [
{
environments: ['default'],
instances: ['instanceId'],
name: 'second-app',
sdks: [
{
name: 'unleash-client-test',
versions: [],
},
],
},
{
environments: ['default'],
instances: ['instanceId'],
name: 'appName',
sdks: [
{
name: 'unleash-client-test',
versions: [],
},
],
},
],
total: 2,
});
});
test('should filter by sdk', async () => {
await app.createFeature('toggle-name-1');
await app.request.post('/api/client/register').send({
appName: metrics.appName,
instanceId: metrics.instanceId,
strategies: ['default'],
sdkVersion: 'unleash-java-test',
started: Date.now(),
interval: 10,
});
const secondApp = 'second-app';
await app.request.post('/api/client/register').send({
appName: secondApp,
instanceId: metrics.instanceId,
strategies: ['default'],
sdkVersion: 'unleash-client-test',
started: Date.now(),
interval: 10,
});
await app.services.clientInstanceService.bulkAdd();
await app.request
.post('/api/client/metrics')
.set('Authorization', defaultToken.secret)
.send(metrics)
.expect(202);
await app.request
.post('/api/client/metrics')
.set('Authorization', defaultToken.secret)
.send({
...metrics,
appName: secondApp,
})
.expect(202);
await app.services.clientMetricsServiceV2.bulkAdd();
const { body } = await app.request
.get('/api/admin/projects/default/applications?&query=java')
.expect('Content-Type', /json/)
.expect(200);
expect(body).toMatchObject({
applications: [
{
environments: ['default'],
instances: ['instanceId'],
name: 'appName',
sdks: [
{
name: 'unleash-java-test',
versions: [],
},
],
},
],
total: 1,
});
});
test('should show correct number of total', async () => {
await app.createFeature('toggle-name-1');
await app.request.post('/api/client/register').send({
appName: metrics.appName,
instanceId: metrics.instanceId,
strategies: ['default'],
sdkVersion: 'unleash-client-test',
started: Date.now(),
interval: 10,
});
await app.request.post('/api/client/register').send({
appName: metrics.appName,
instanceId: 'another-instance',
strategies: ['default'],
sdkVersion: 'unleash-client-test',
started: Date.now(),
interval: 10,
});
const secondApp = 'second-app';
await app.request.post('/api/client/register').send({
appName: secondApp,
instanceId: metrics.instanceId,
strategies: ['default'],
sdkVersion: 'unleash-client-test',
started: Date.now(),
interval: 10,
});
await app.services.clientInstanceService.bulkAdd();
await app.request
.post('/api/client/metrics')
.set('Authorization', defaultToken.secret)
.send(metrics)
.expect(202);
await app.request
.post('/api/client/metrics')
.set('Authorization', defaultToken.secret)
.send({
...metrics,
appName: secondApp,
})
.expect(202);
await app.services.clientMetricsServiceV2.bulkAdd();
const { body } = await app.request
.get('/api/admin/projects/default/applications?sortOrder=desc&limit=1')
.expect('Content-Type', /json/)
.expect(200);
expect(body).toMatchObject({
applications: [
{
environments: ['default'],
instances: ['instanceId'],
name: 'second-app',
sdks: [
{
name: 'unleash-client-test',
versions: [],
},
],
},
],
total: 2,
});
}); });

View File

@ -267,10 +267,30 @@ export default class ProjectController extends Controller {
throw new NotFoundError(); throw new NotFoundError();
} }
const { query, offset, limit = '50', sortOrder, sortBy } = req.query;
const { projectId } = req.params; const { projectId } = req.params;
const applications = const normalizedQuery = query
await this.projectService.getApplications(projectId); ?.split(',')
.map((query) => query.trim())
.filter((query) => query);
const normalizedLimit =
Number(limit) > 0 && Number(limit) <= 100 ? Number(limit) : 25;
const normalizedOffset = Number(offset) > 0 ? Number(offset) : 0;
const normalizedSortBy: string = sortBy ? sortBy : 'appName';
const normalizedSortOrder =
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
const applications = await this.projectService.getApplications({
searchParams: normalizedQuery,
project: projectId,
offset: normalizedOffset,
limit: normalizedLimit,
sortBy: normalizedSortBy,
sortOrder: normalizedSortOrder,
});
this.openApiService.respondWithValidation( this.openApiService.respondWithValidation(
200, 200,

View File

@ -42,8 +42,8 @@ import {
IProjectUpdate, IProjectUpdate,
IProjectHealth, IProjectHealth,
SYSTEM_USER, SYSTEM_USER,
IProjectApplication,
IProjectStore, IProjectStore,
IProjectApplications,
} from '../../types'; } from '../../types';
import { import {
IProjectAccessModel, IProjectAccessModel,
@ -65,6 +65,7 @@ import { checkFeatureNamingData } from '../feature-naming-pattern/feature-naming
import { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType'; import { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType';
import EventService from '../events/event-service'; import EventService from '../events/event-service';
import { import {
IProjectApplicationsSearchParams,
IProjectEnterpriseSettingsUpdate, IProjectEnterpriseSettingsUpdate,
IProjectQuery, IProjectQuery,
} from './project-store-type'; } from './project-store-type';
@ -901,9 +902,11 @@ export default class ProjectService {
}; };
} }
async getApplications(projectId: string): Promise<IProjectApplication[]> { async getApplications(
searchParams: IProjectApplicationsSearchParams,
): Promise<IProjectApplications> {
const applications = const applications =
await this.projectStore.getApplicationsByProject(projectId); await this.projectStore.getApplicationsByProject(searchParams);
return applications; return applications;
} }

View File

@ -7,7 +7,7 @@ import {
IEnvironment, IEnvironment,
IFeatureNaming, IFeatureNaming,
IProject, IProject,
IProjectApplication, IProjectApplications,
IProjectWithCount, IProjectWithCount,
ProjectMode, ProjectMode,
} from '../../types/model'; } from '../../types/model';
@ -55,6 +55,15 @@ export type ProjectEnvironment = {
defaultStrategy?: CreateFeatureStrategySchema; defaultStrategy?: CreateFeatureStrategySchema;
}; };
export interface IProjectApplicationsSearchParams {
searchParams?: string[];
project?: string;
offset: number;
limit: number;
sortBy: string;
sortOrder: 'asc' | 'desc';
}
export interface IProjectStore extends Store<IProject, string> { export interface IProjectStore extends Store<IProject, string> {
hasProject(id: string): Promise<boolean>; hasProject(id: string): Promise<boolean>;
@ -122,5 +131,7 @@ export interface IProjectStore extends Store<IProject, string> {
isFeatureLimitReached(id: string): Promise<boolean>; isFeatureLimitReached(id: string): Promise<boolean>;
getProjectModeCounts(): Promise<ProjectModeCount[]>; getProjectModeCounts(): Promise<ProjectModeCount[]>;
getApplicationsByProject(projectId: string): Promise<IProjectApplication[]>; getApplicationsByProject(
searchParams: IProjectApplicationsSearchParams,
): Promise<IProjectApplications>;
} }

View File

@ -7,6 +7,7 @@ import {
IFlagResolver, IFlagResolver,
IProject, IProject,
IProjectApplication, IProjectApplication,
IProjectApplications,
IProjectUpdate, IProjectUpdate,
IProjectWithCount, IProjectWithCount,
ProjectMode, ProjectMode,
@ -19,6 +20,7 @@ import {
IProjectEnterpriseSettingsUpdate, IProjectEnterpriseSettingsUpdate,
IProjectStore, IProjectStore,
ProjectEnvironment, ProjectEnvironment,
IProjectApplicationsSearchParams,
} from '../../features/project/project-store-type'; } from '../../features/project/project-store-type';
import { DEFAULT_ENV } from '../../util'; import { DEFAULT_ENV } from '../../util';
import metricsHelper from '../../util/metrics-helper'; import metricsHelper from '../../util/metrics-helper';
@ -27,6 +29,7 @@ import EventEmitter from 'events';
import { Db } from '../../db/db'; import { Db } from '../../db/db';
import Raw = Knex.Raw; import Raw = Knex.Raw;
import { CreateFeatureStrategySchema } from '../../openapi'; import { CreateFeatureStrategySchema } from '../../openapi';
import { applySearchFilters } from '../feature-search/search-utils';
const COLUMNS = [ const COLUMNS = [
'id', 'id',
@ -579,32 +582,66 @@ class ProjectStore implements IProjectStore {
} }
async getApplicationsByProject( async getApplicationsByProject(
projectId: string, params: IProjectApplicationsSearchParams,
): Promise<IProjectApplication[]> { ): Promise<IProjectApplications> {
const { project, limit, sortOrder, sortBy, searchParams, offset } =
params;
const validatedSortOrder =
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
const query = this.db const query = this.db
.with('applications', (qb) => { .with('applications', (qb) => {
qb.select('project', 'app_name', 'environment') qb.select('project', 'app_name', 'environment')
.distinct() .distinct()
.from('client_metrics_env as cme') .from('client_metrics_env as cme')
.leftJoin('features as f', 'cme.feature_name', 'f.name') .leftJoin('features as f', 'cme.feature_name', 'f.name')
.where('project', projectId); .where('project', project);
}) })
.select( .with('ranked', (qb) => {
'a.app_name', applySearchFilters(qb, searchParams, [
'a.environment', 'a.app_name',
'ci.instance_id',
'ci.sdk_version',
)
.from('applications as a')
.leftJoin('client_instances as ci', function () {
this.on('ci.app_name', 'a.app_name').andOn(
'ci.environment',
'a.environment', 'a.environment',
); 'ci.instance_id',
}); 'ci.sdk_version',
]);
qb.select(
'a.app_name',
'a.environment',
'ci.instance_id',
'ci.sdk_version',
this.db.raw(
`DENSE_RANK() OVER (ORDER BY a.app_name ${validatedSortOrder}) AS rank`,
),
)
.from('applications as a')
.leftJoin('client_instances as ci', function () {
this.on('ci.app_name', '=', 'a.app_name').andOn(
'ci.environment',
'=',
'a.environment',
);
});
})
.with(
'final_ranks',
this.db.raw(
'select row_number() over (order by min(rank)) as final_rank from ranked group by app_name',
),
)
.with(
'total',
this.db.raw('select count(*) as total from final_ranks'),
)
.select('*')
.from('ranked')
.joinRaw('CROSS JOIN total')
.whereBetween('rank', [offset + 1, offset + limit]);
const rows = await query; const rows = await query;
const applications = this.getAggregatedApplicationsData(rows); const applications = this.getAggregatedApplicationsData(rows);
return applications; return {
applications,
total: Number(rows[0].total) || 0,
};
} }
async getDefaultStrategy( async getDefaultStrategy(
@ -751,7 +788,10 @@ class ProjectStore implements IProjectStore {
let sdk = entry.sdks.find((sdk) => sdk.name === sdkName); let sdk = entry.sdks.find((sdk) => sdk.name === sdkName);
if (!sdk) { if (!sdk) {
sdk = { name: sdkName, versions: [] }; sdk = {
name: sdkName,
versions: [],
};
entry.sdks.push(sdk); entry.sdks.push(sdk);
} }

View File

@ -2,19 +2,22 @@ import { validateSchema } from '../validate';
import { ProjectApplicationsSchema } from './project-applications-schema'; import { ProjectApplicationsSchema } from './project-applications-schema';
test('projectApplicationsSchema', () => { test('projectApplicationsSchema', () => {
const data: ProjectApplicationsSchema = [ const data: ProjectApplicationsSchema = {
{ total: 55,
name: 'my-weba-app', applications: [
environments: ['development', 'production'], {
instances: ['instance-414122'], name: 'my-weba-app',
sdks: [ environments: ['development', 'production'],
{ instances: ['instance-414122'],
name: 'unleash-client-node', sdks: [
versions: ['4.1.1'], {
}, name: 'unleash-client-node',
], versions: ['4.1.1'],
}, },
]; ],
},
],
};
expect( expect(
validateSchema('#/components/schemas/projectApplicationsSchema', data), validateSchema('#/components/schemas/projectApplicationsSchema', data),

View File

@ -4,10 +4,22 @@ import { projectApplicationSdkSchema } from './project-application-sdk-schema';
export const projectApplicationsSchema = { export const projectApplicationsSchema = {
$id: '#/components/schemas/projectApplicationsSchema', $id: '#/components/schemas/projectApplicationsSchema',
type: 'array', type: 'object',
description: 'A list of project applications', description: 'A list of project applications',
items: { required: ['total', 'applications'],
$ref: '#/components/schemas/projectApplicationSchema', properties: {
total: {
type: 'integer',
example: 50,
description: 'The total number of project applications.',
},
applications: {
type: 'array',
items: {
$ref: '#/components/schemas/projectApplicationSchema',
},
description: 'All applications defined for a specific project.',
},
}, },
components: { components: {
schemas: { schemas: {

View File

@ -478,6 +478,11 @@ export interface IProject {
featureNaming?: IFeatureNaming; featureNaming?: IFeatureNaming;
} }
export interface IProjectApplications {
applications: IProjectApplication[];
total: number;
}
export interface IProjectApplication { export interface IProjectApplication {
name: string; name: string;
environments: string[]; environments: string[];

View File

@ -1,7 +1,7 @@
import { import {
IEnvironment, IEnvironment,
IProject, IProject,
IProjectApplication, IProjectApplications,
IProjectStore, IProjectStore,
IProjectWithCount, IProjectWithCount,
} from '../../lib/types'; } from '../../lib/types';
@ -13,6 +13,7 @@ import {
} from '../../lib/features/project/project-store'; } from '../../lib/features/project/project-store';
import { CreateFeatureStrategySchema } from '../../lib/openapi'; import { CreateFeatureStrategySchema } from '../../lib/openapi';
import { import {
IProjectApplicationsSearchParams,
IProjectHealthUpdate, IProjectHealthUpdate,
IProjectInsert, IProjectInsert,
ProjectEnvironment, ProjectEnvironment,
@ -209,8 +210,8 @@ export default class FakeProjectStore implements IProjectStore {
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
getApplicationsByProject( getApplicationsByProject(
projectId: string, searchParams: IProjectApplicationsSearchParams,
): Promise<IProjectApplication[]> { ): Promise<IProjectApplications> {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
} }