feat(core): Add sorting to GET /workflows endpoint (#13029)

This commit is contained in:
Ricardo Espinoza 2025-02-04 10:52:35 -05:00 committed by GitHub
parent 18eaa5423d
commit b60011a180
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 246 additions and 1 deletions

View file

@ -158,6 +158,11 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
findManyOptions.order = { updatedAt: 'ASC' };
}
if (options.sortBy) {
const [column, order] = options.sortBy.split(':');
findManyOptions.order = { [column]: order };
}
if (relations.length > 0) {
findManyOptions.relations = relations;
}

View file

@ -6,6 +6,8 @@ import { selectListQueryMiddleware } from '@/middlewares/list-query/select';
import type { ListQuery } from '@/requests';
import * as ResponseHelper from '@/response-helper';
import { sortByQueryMiddleware } from '../sort-by';
describe('List query middleware', () => {
let mockReq: ListQuery.Request;
let mockRes: Response;
@ -174,6 +176,84 @@ describe('List query middleware', () => {
});
});
describe('Query sort by', () => {
const validCases: Array<{ name: string; value: ListQuery.Workflow.SortOrder }> = [
{
name: 'sorting by name asc',
value: 'name:asc',
},
{
name: 'sorting by name desc',
value: 'name:desc',
},
{
name: 'sorting by createdAt asc',
value: 'createdAt:asc',
},
{
name: 'sorting by createdAt desc',
value: 'createdAt:desc',
},
{
name: 'sorting by updatedAt asc',
value: 'updatedAt:asc',
},
{
name: 'sorting by updatedAt desc',
value: 'updatedAt:desc',
},
];
const invalidCases: Array<{ name: string; value: string }> = [
{
name: 'sorting by invalid column',
value: 'test:asc',
},
{
name: 'sorting by valid column without order',
value: 'name',
},
{
name: 'sorting by valid column with invalid order',
value: 'name:test',
},
];
test.each(validCases)('should succeed validation when $name', async ({ value }) => {
mockReq.query = {
sortBy: value,
};
sortByQueryMiddleware(...args);
expect(mockReq.listQueryOptions).toMatchObject(
expect.objectContaining({
sortBy: value,
}),
);
expect(nextFn).toBeCalledTimes(1);
});
test.each(invalidCases)('should fail validation when $name', async ({ value }) => {
mockReq.query = {
sortBy: value as ListQuery.Workflow.SortOrder,
};
sortByQueryMiddleware(...args);
expect(sendErrorResponse).toHaveBeenCalledTimes(1);
});
test('should not pass sortBy to listQueryOptions if not provided', async () => {
mockReq.query = {};
sortByQueryMiddleware(...args);
expect(mockReq.listQueryOptions).toBeUndefined();
expect(nextFn).toBeCalledTimes(1);
});
});
describe('Combinations', () => {
test('should combine filter with select', async () => {
mockReq.query = { filter: '{ "name": "My Workflow" }', select: '["name", "id"]' };

View file

@ -0,0 +1,22 @@
import type { ValidatorConstraintInterface, ValidationArguments } from 'class-validator';
import { IsString, Validate, ValidatorConstraint } from 'class-validator';
@ValidatorConstraint({ name: 'WorkflowSortByParameter', async: false })
export class WorkflowSortByParameter implements ValidatorConstraintInterface {
validate(text: string, _: ValidationArguments) {
const [column, order] = text.split(':');
if (!column || !order) return false;
return ['name', 'createdAt', 'updatedAt'].includes(column) && ['asc', 'desc'].includes(order);
}
defaultMessage(_: ValidationArguments) {
return 'Invalid value for sortBy parameter';
}
}
export class WorkflowSorting {
@IsString()
@Validate(WorkflowSortByParameter)
sortBy?: string;
}

View file

@ -1,10 +1,11 @@
import type { NextFunction, Response } from 'express';
import { type NextFunction, type Response } from 'express';
import type { ListQuery } from '@/requests';
import { filterListQueryMiddleware } from './filter';
import { paginationListQueryMiddleware } from './pagination';
import { selectListQueryMiddleware } from './select';
import { sortByQueryMiddleware } from './sort-by';
export type ListQueryMiddleware = (
req: ListQuery.Request,
@ -16,4 +17,5 @@ export const listQueryMiddleware: ListQueryMiddleware[] = [
filterListQueryMiddleware,
selectListQueryMiddleware,
paginationListQueryMiddleware,
sortByQueryMiddleware,
];

View file

@ -0,0 +1,39 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import type { RequestHandler } from 'express';
import { ApplicationError } from 'n8n-workflow';
import type { ListQuery } from '@/requests';
import * as ResponseHelper from '@/response-helper';
import { toError } from '@/utils';
import { WorkflowSorting } from './dtos/workflow.sort-by.dto';
export const sortByQueryMiddleware: RequestHandler = (req: ListQuery.Request, res, next) => {
const { sortBy } = req.query;
if (!sortBy) return next();
let SortBy;
try {
if (req.baseUrl.endsWith('workflows')) {
SortBy = WorkflowSorting;
} else {
return next();
}
const validationResponse = validateSync(plainToInstance(SortBy, { sortBy }));
if (validationResponse.length) {
const validationError = validationResponse[0];
throw new ApplicationError(validationError.constraints?.workflowSortBy ?? '');
}
req.listQueryOptions = { ...req.listQueryOptions, sortBy };
next();
} catch (maybeError) {
ResponseHelper.sendErrorResponse(res, toError(maybeError));
}
};

View file

@ -62,6 +62,7 @@ export namespace ListQuery {
skip?: string;
take?: string;
select?: string;
sortBy?: string;
};
export type Options = {
@ -69,6 +70,7 @@ export namespace ListQuery {
select?: Record<string, true>;
skip?: number;
take?: number;
sortBy?: string;
};
/**
@ -82,6 +84,10 @@ export namespace ListQuery {
type SharedField = Partial<Pick<WorkflowEntity, 'shared'>>;
type SortingField = 'createdAt' | 'updatedAt' | 'name';
export type SortOrder = `${SortingField}:asc` | `${SortingField}:desc`;
type OwnedByField = { ownedBy: SlimUser | null; homeProject: SlimProject | null };
export type Plain = BaseFields;

View file

@ -854,6 +854,97 @@ describe('GET /workflows', () => {
});
});
});
describe('sortBy', () => {
test('should fail when trying to sort by non sortable column', async () => {
await authOwnerAgent.get('/workflows').query('sortBy=nonSortableColumn:asc').expect(500);
});
test('should sort by createdAt column', async () => {
await createWorkflow({ name: 'First' }, owner);
await createWorkflow({ name: 'Second' }, owner);
let response = await authOwnerAgent
.get('/workflows')
.query('sortBy=createdAt:asc')
.expect(200);
expect(response.body).toEqual({
count: 2,
data: arrayContaining([
expect.objectContaining({ name: 'First' }),
expect.objectContaining({ name: 'Second' }),
]),
});
response = await authOwnerAgent.get('/workflows').query('sortBy=createdAt:desc').expect(200);
expect(response.body).toEqual({
count: 2,
data: arrayContaining([
expect.objectContaining({ name: 'Second' }),
expect.objectContaining({ name: 'First' }),
]),
});
});
test('should sort by name column', async () => {
await createWorkflow({ name: 'a' }, owner);
await createWorkflow({ name: 'b' }, owner);
let response;
response = await authOwnerAgent.get('/workflows').query('sortBy=name:asc').expect(200);
expect(response.body).toEqual({
count: 2,
data: arrayContaining([
expect.objectContaining({ name: 'a' }),
expect.objectContaining({ name: 'b' }),
]),
});
response = await authOwnerAgent.get('/workflows').query('sortBy=name:desc').expect(200);
expect(response.body).toEqual({
count: 2,
data: arrayContaining([
expect.objectContaining({ name: 'b' }),
expect.objectContaining({ name: 'a' }),
]),
});
});
test('should sort by updatedAt column', async () => {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 10);
await createWorkflow({ name: 'First', updatedAt: futureDate }, owner);
await createWorkflow({ name: 'Second' }, owner);
let response;
response = await authOwnerAgent.get('/workflows').query('sortBy=updatedAt:asc').expect(200);
expect(response.body).toEqual({
count: 2,
data: arrayContaining([
expect.objectContaining({ name: 'Second' }),
expect.objectContaining({ name: 'First' }),
]),
});
response = await authOwnerAgent.get('/workflows').query('sortBy=name:desc').expect(200);
expect(response.body).toEqual({
count: 2,
data: arrayContaining([
expect.objectContaining({ name: 'First' }),
expect.objectContaining({ name: 'Second' }),
]),
});
});
});
});
describe('PATCH /workflows/:workflowId', () => {