perf(core): Add filtering and pagination to GET /workflows (#6845)

* Initial setup

* Specify max paginated items

* Simplify

* Add tests

* Add more tests

* Add migrations

* Add top-level property

* Add field selection

* Cleanup

* Rename `total` to `count`

* More cleanup

* Move query logic into `WorkflowRepository`

* Create `AbstractRepository`

* Cleanup

* Fix name

* Remove leftover comments

* Replace reference

* Add default for `rawSkip`

* Remove unneeded typing

* Switch to `class-validator`

* Simplify

* Simplify

* Type as optional

* Make typing more accurate

* Fix lint

* Use `getOwnPropertyNames`

* Use DSL

* Set schema at repo level

* Cleanup

* Remove comment

* Refactor repository methods to middleware

* Add middleware tests

* Remove old test files

* Remove generic experiment

* Reuse `reportError`

* Remove unused type

* Cleanup

* Improve wording

* Reduce diff

* Add missing mw

* Use `Container.get`

* Adjust lint rule

* Reorganize into subdir

* Remove unused directive

* Remove nodes

* Silly mistake

* Validate take

* refactor(core): Adjust index handling in new migrations DSL (no-changelog) (#6876)

* refactor(core): Adjust index handling in new migrations DSL (no-changelog)

* Account for custom index name

* Also for dropping

* Fix `select` issue with `relations`

* Tighten validation

* Ensure `ownerId` is not added when specifying `select`
This commit is contained in:
Iván Ovejero
2023-08-09 12:30:02 +02:00
committed by GitHub
parent f8ad543af5
commit dceff675ec
24 changed files with 481 additions and 95 deletions

View File

@@ -6,7 +6,7 @@ import * as WorkflowHelpers from '@/WorkflowHelpers';
import config from '@/config';
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { validateEntity } from '@/GenericHelpers';
import type { WorkflowRequest } from '@/requests';
import type { ListQueryRequest, WorkflowRequest } from '@/requests';
import { isSharingEnabled, rightDiff } from '@/UserManagement/UserManagementHelper';
import { EEWorkflowsService as EEWorkflows } from './workflows.services.ee';
import { ExternalHooks } from '@/ExternalHooks';
@@ -20,6 +20,8 @@ import { In } from 'typeorm';
import { Container } from 'typedi';
import { InternalHooks } from '@/InternalHooks';
import { RoleService } from '@/services/role.service';
import * as utils from '@/utils';
import { listQueryMiddleware } from '@/middlewares';
import { TagRepository } from '@/databases/repositories';
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -203,17 +205,27 @@ EEWorkflowController.post(
*/
EEWorkflowController.get(
'/',
ResponseHelper.send(async (req: WorkflowRequest.GetAll) => {
const [workflows, workflowOwnerRole] = await Promise.all([
EEWorkflows.getMany(req.user, req.query.filter),
Container.get(RoleService).findWorkflowOwnerRole(),
]);
listQueryMiddleware,
async (req: ListQueryRequest, res: express.Response) => {
try {
const [workflows, count] = await EEWorkflows.getMany(req.user, req.listQueryOptions);
return workflows.map((workflow) => {
EEWorkflows.addOwnerId(workflow, workflowOwnerRole);
return workflow;
});
}),
let data;
if (req.listQueryOptions?.select) {
data = workflows;
} else {
const role = await Container.get(RoleService).findWorkflowOwnerRole();
data = workflows.map((w) => EEWorkflows.addOwnerId(w, role));
}
res.json({ count, data });
} catch (maybeError) {
const error = utils.toError(maybeError);
ResponseHelper.reportError(error);
ResponseHelper.sendErrorResponse(res, error);
}
},
);
EEWorkflowController.patch(

View File

@@ -15,7 +15,7 @@ import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { validateEntity } from '@/GenericHelpers';
import { ExternalHooks } from '@/ExternalHooks';
import { getLogger } from '@/Logger';
import type { WorkflowRequest } from '@/requests';
import type { ListQueryRequest, WorkflowRequest } from '@/requests';
import { isBelowOnboardingThreshold } from '@/WorkflowHelpers';
import { EEWorkflowController } from './workflows.controller.ee';
import { WorkflowsService } from './workflows.services';
@@ -24,6 +24,8 @@ import { In } from 'typeorm';
import { Container } from 'typedi';
import { InternalHooks } from '@/InternalHooks';
import { RoleService } from '@/services/role.service';
import * as utils from '@/utils';
import { listQueryMiddleware } from '@/middlewares';
import { TagRepository } from '@/databases/repositories';
export const workflowsController = express.Router();
@@ -116,9 +118,18 @@ workflowsController.post(
*/
workflowsController.get(
'/',
ResponseHelper.send(async (req: WorkflowRequest.GetAll) => {
return WorkflowsService.getMany(req.user, req.query.filter);
}),
listQueryMiddleware,
async (req: ListQueryRequest, res: express.Response) => {
try {
const [data, count] = await WorkflowsService.getMany(req.user, req.listQueryOptions);
res.json({ count, data });
} catch (maybeError) {
const error = utils.toError(maybeError);
ResponseHelper.reportError(error);
ResponseHelper.sendErrorResponse(res, error);
}
},
);
/**

View File

@@ -88,11 +88,12 @@ export class EEWorkflowsService extends WorkflowsService {
return transaction.save(newSharedWorkflows);
}
static addOwnerId(workflow: WorkflowForList, workflowOwnerRole: Role): void {
static addOwnerId(workflow: WorkflowForList, workflowOwnerRole: Role) {
const ownerId = workflow.shared?.find(({ roleId }) => String(roleId) === workflowOwnerRole.id)
?.userId;
workflow.ownedBy = ownerId ? { id: ownerId } : null;
delete workflow.shared;
return workflow;
}
static addOwnerAndSharings(workflow: WorkflowWithSharingsAndCredentials): void {

View File

@@ -1,9 +1,8 @@
import { Container } from 'typedi';
import { validate as jsonSchemaValidate } from 'jsonschema';
import type { INode, IPinData, JsonObject } from 'n8n-workflow';
import { NodeApiError, jsonParse, LoggerProxy, Workflow } from 'n8n-workflow';
import type { FindOptionsSelect, FindOptionsWhere, UpdateResult } from 'typeorm';
import { In } from 'typeorm';
import type { INode, IPinData } from 'n8n-workflow';
import { NodeApiError, LoggerProxy, Workflow } from 'n8n-workflow';
import type { FindManyOptions, FindOptionsSelect, FindOptionsWhere, UpdateResult } from 'typeorm';
import { In, Like } from 'typeorm';
import pick from 'lodash/pick';
import { v4 as uuid } from 'uuid';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
@@ -18,7 +17,7 @@ import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { validateEntity } from '@/GenericHelpers';
import { ExternalHooks } from '@/ExternalHooks';
import * as TagHelpers from '@/TagHelpers';
import type { WorkflowRequest } from '@/requests';
import type { ListQueryOptions, WorkflowRequest } from '@/requests';
import type { IWorkflowDb, IWorkflowExecutionDataProcess } from '@/Interfaces';
import { NodeTypes } from '@/NodeTypes';
import { WorkflowRunner } from '@/WorkflowRunner';
@@ -26,25 +25,9 @@ import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'
import { TestWebhooks } from '@/TestWebhooks';
import { getSharedWorkflowIds } from '@/WorkflowHelpers';
import { isSharingEnabled, whereClause } from '@/UserManagement/UserManagementHelper';
import type { WorkflowForList } from '@/workflows/workflows.types';
import { InternalHooks } from '@/InternalHooks';
export type IGetWorkflowsQueryFilter = Pick<
FindOptionsWhere<WorkflowEntity>,
'id' | 'name' | 'active'
>;
const schemaGetWorkflowsQueryFilter = {
$id: '/IGetWorkflowsQueryFilter',
type: 'object',
properties: {
id: { anyOf: [{ type: 'integer' }, { type: 'string' }] },
name: { type: 'string' },
active: { type: 'boolean' },
},
};
const allowedWorkflowsQueryFilterFields = Object.keys(schemaGetWorkflowsQueryFilter.properties);
import type { WorkflowForList } from './workflows.types';
import { WorkflowRepository } from '@/databases/repositories';
export class WorkflowsService {
static async getSharing(
@@ -116,71 +99,70 @@ export class WorkflowsService {
return getSharedWorkflowIds(user, roles);
}
static async getMany(user: User, rawFilter: string): Promise<WorkflowForList[]> {
static async getMany(
user: User,
options?: ListQueryOptions,
): Promise<[WorkflowForList[], number]> {
const sharedWorkflowIds = await this.getWorkflowIdsForUser(user, ['owner']);
if (sharedWorkflowIds.length === 0) {
// return early since without shared workflows there can be no hits
// (note: getSharedWorkflowIds() returns _all_ workflow ids for global owners)
return [];
return [[], 0];
}
let filter: IGetWorkflowsQueryFilter = {};
if (rawFilter) {
try {
const filterJson: JsonObject = jsonParse(rawFilter);
if (filterJson) {
Object.keys(filterJson).map((key) => {
if (!allowedWorkflowsQueryFilterFields.includes(key)) delete filterJson[key];
});
if (jsonSchemaValidate(filterJson, schemaGetWorkflowsQueryFilter).valid) {
filter = filterJson as IGetWorkflowsQueryFilter;
}
}
} catch (error) {
LoggerProxy.error('Failed to parse filter', {
userId: user.id,
filter,
});
throw new ResponseHelper.InternalServerError(
'Parameter "filter" contained invalid JSON string.',
);
}
}
const filter = options?.filter ?? {};
// safeguard against querying ids not shared with the user
const workflowId = filter?.id?.toString();
if (workflowId !== undefined && !sharedWorkflowIds.includes(workflowId)) {
LoggerProxy.verbose(`User ${user.id} attempted to query non-shared workflow ${workflowId}`);
return [];
return [[], 0];
}
const select: FindOptionsSelect<WorkflowEntity> = {
const DEFAULT_SELECT: FindOptionsSelect<WorkflowEntity> = {
id: true,
name: true,
active: true,
createdAt: true,
updatedAt: true,
};
const select: FindOptionsSelect<WorkflowEntity> = options?.select ?? DEFAULT_SELECT;
const relations: string[] = [];
if (!config.getEnv('workflowTagsDisabled')) {
const isDefaultSelect = options?.select === undefined;
if (isDefaultSelect && !config.getEnv('workflowTagsDisabled')) {
relations.push('tags');
select.tags = { id: true, name: true };
}
if (isSharingEnabled()) {
if (isDefaultSelect && isSharingEnabled()) {
relations.push('shared');
select.shared = { userId: true, roleId: true };
select.versionId = true;
}
filter.id = In(sharedWorkflowIds);
return Db.collections.Workflow.find({
if (typeof filter.name === 'string' && filter.name !== '') {
filter.name = Like(`%${filter.name}%`);
}
const findManyOptions: FindManyOptions<WorkflowEntity> = {
select,
relations,
where: filter,
order: { updatedAt: 'DESC' },
});
order: { updatedAt: 'ASC' },
};
if (options?.take) {
findManyOptions.skip = options.skip;
findManyOptions.take = options.take;
}
return Container.get(WorkflowRepository).findAndCount(findManyOptions);
}
static async update(