Skip to content

Commit

Permalink
feat(core): Add sorting to GET /workflows endpoint (#13029)
Browse files Browse the repository at this point in the history
  • Loading branch information
RicardoE105 authored Feb 4, 2025
1 parent 18eaa54 commit b60011a
Show file tree
Hide file tree
Showing 7 changed files with 246 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"]' };
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 3 additions & 1 deletion packages/cli/src/middlewares/list-query/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,4 +17,5 @@ export const listQueryMiddleware: ListQueryMiddleware[] = [
filterListQueryMiddleware,
selectListQueryMiddleware,
paginationListQueryMiddleware,
sortByQueryMiddleware,
];
39 changes: 39 additions & 0 deletions packages/cli/src/middlewares/list-query/sort-by.ts
Original file line number Diff line number Diff line change
@@ -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));
}
};
6 changes: 6 additions & 0 deletions packages/cli/src/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,15 @@ export namespace ListQuery {
skip?: string;
take?: string;
select?: string;
sortBy?: string;
};

export type Options = {
filter?: Record<string, unknown>;
select?: Record<string, true>;
skip?: number;
take?: number;
sortBy?: string;
};

/**
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down

0 comments on commit b60011a

Please sign in to comment.