mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
refactor(core): Update workflowRepository.getMany to use query builder (no-changelog) (#13207)
This commit is contained in:
@@ -1,22 +1,22 @@
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
import {
|
||||
DataSource,
|
||||
Repository,
|
||||
In,
|
||||
Like,
|
||||
type UpdateResult,
|
||||
type FindOptionsWhere,
|
||||
type FindOptionsSelect,
|
||||
type FindManyOptions,
|
||||
type FindOptionsRelations,
|
||||
import { DataSource, Repository, In, Like } from '@n8n/typeorm';
|
||||
import type {
|
||||
SelectQueryBuilder,
|
||||
UpdateResult,
|
||||
FindOptionsWhere,
|
||||
FindOptionsSelect,
|
||||
FindManyOptions,
|
||||
FindOptionsRelations,
|
||||
} from '@n8n/typeorm';
|
||||
|
||||
import type { ListQuery } from '@/requests';
|
||||
import { isStringArray } from '@/utils';
|
||||
|
||||
import { TagEntity } from '../entities/tag-entity';
|
||||
import { WebhookEntity } from '../entities/webhook-entity';
|
||||
import { WorkflowEntity } from '../entities/workflow-entity';
|
||||
import { WorkflowTagMapping } from '../entities/workflow-tag-mapping';
|
||||
|
||||
@Service()
|
||||
export class WorkflowRepository extends Repository<WorkflowEntity> {
|
||||
@@ -99,83 +99,20 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async getMany(sharedWorkflowIds: string[], originalOptions: ListQuery.Options = {}) {
|
||||
const options = structuredClone(originalOptions);
|
||||
if (sharedWorkflowIds.length === 0) return { workflows: [], count: 0 };
|
||||
|
||||
if (typeof options?.filter?.projectId === 'string' && options.filter.projectId !== '') {
|
||||
options.filter.shared = { projectId: options.filter.projectId };
|
||||
delete options.filter.projectId;
|
||||
async getMany(sharedWorkflowIds: string[], options: ListQuery.Options = {}) {
|
||||
if (sharedWorkflowIds.length === 0) {
|
||||
return { workflows: [], count: 0 };
|
||||
}
|
||||
|
||||
const where: FindOptionsWhere<WorkflowEntity> = {
|
||||
...options?.filter,
|
||||
id: In(sharedWorkflowIds),
|
||||
};
|
||||
const qb = this.createBaseQuery(sharedWorkflowIds);
|
||||
|
||||
const reqTags = options?.filter?.tags;
|
||||
this.applyFilters(qb, options.filter);
|
||||
this.applySelect(qb, options.select);
|
||||
this.applyRelations(qb, options.select);
|
||||
this.applySorting(qb, options.sortBy);
|
||||
this.applyPagination(qb, options);
|
||||
|
||||
if (isStringArray(reqTags)) {
|
||||
where.tags = reqTags.map((tag) => ({ name: tag }));
|
||||
}
|
||||
|
||||
type Select = FindOptionsSelect<WorkflowEntity> & { ownedBy?: true };
|
||||
|
||||
const select: Select = options?.select
|
||||
? { ...options.select } // copy to enable field removal without affecting original
|
||||
: {
|
||||
name: true,
|
||||
active: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
versionId: true,
|
||||
shared: { role: true },
|
||||
};
|
||||
|
||||
delete select?.ownedBy; // remove non-entity field, handled after query
|
||||
|
||||
const relations: string[] = [];
|
||||
|
||||
const areTagsEnabled = !this.globalConfig.tags.disabled;
|
||||
const isDefaultSelect = options?.select === undefined;
|
||||
const areTagsRequested = isDefaultSelect || options?.select?.tags === true;
|
||||
const isOwnedByIncluded = isDefaultSelect || options?.select?.ownedBy === true;
|
||||
|
||||
if (areTagsEnabled && areTagsRequested) {
|
||||
relations.push('tags');
|
||||
select.tags = { id: true, name: true };
|
||||
}
|
||||
|
||||
if (isOwnedByIncluded) relations.push('shared', 'shared.project');
|
||||
|
||||
if (typeof where.name === 'string' && where.name !== '') {
|
||||
where.name = Like(`%${where.name}%`);
|
||||
}
|
||||
|
||||
const findManyOptions: FindManyOptions<WorkflowEntity> = {
|
||||
select: { ...select, id: true },
|
||||
where,
|
||||
};
|
||||
|
||||
if (isDefaultSelect || options?.select?.updatedAt === true) {
|
||||
findManyOptions.order = { updatedAt: 'ASC' };
|
||||
}
|
||||
|
||||
if (options.sortBy) {
|
||||
const [column, order] = options.sortBy.split(':');
|
||||
findManyOptions.order = { [column]: order };
|
||||
}
|
||||
|
||||
if (relations.length > 0) {
|
||||
findManyOptions.relations = relations;
|
||||
}
|
||||
|
||||
if (options?.take) {
|
||||
findManyOptions.skip = options.skip;
|
||||
findManyOptions.take = options.take;
|
||||
}
|
||||
|
||||
const [workflows, count] = (await this.findAndCount(findManyOptions)) as [
|
||||
const [workflows, count] = (await qb.getManyAndCount()) as [
|
||||
ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[],
|
||||
number,
|
||||
];
|
||||
@@ -183,6 +120,201 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
||||
return { workflows, count };
|
||||
}
|
||||
|
||||
private createBaseQuery(sharedWorkflowIds: string[]): SelectQueryBuilder<WorkflowEntity> {
|
||||
return this.createQueryBuilder('workflow').where('workflow.id IN (:...sharedWorkflowIds)', {
|
||||
sharedWorkflowIds,
|
||||
});
|
||||
}
|
||||
|
||||
private applyFilters(
|
||||
qb: SelectQueryBuilder<WorkflowEntity>,
|
||||
filter?: ListQuery.Options['filter'],
|
||||
): void {
|
||||
if (!filter) return;
|
||||
|
||||
this.applyNameFilter(qb, filter);
|
||||
this.applyActiveFilter(qb, filter);
|
||||
this.applyTagsFilter(qb, filter);
|
||||
this.applyProjectFilter(qb, filter);
|
||||
}
|
||||
|
||||
private applyNameFilter(
|
||||
qb: SelectQueryBuilder<WorkflowEntity>,
|
||||
filter: ListQuery.Options['filter'],
|
||||
): void {
|
||||
if (typeof filter?.name === 'string' && filter.name !== '') {
|
||||
qb.andWhere('LOWER(workflow.name) LIKE :name', {
|
||||
name: `%${filter.name.toLowerCase()}%`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private applyActiveFilter(
|
||||
qb: SelectQueryBuilder<WorkflowEntity>,
|
||||
filter: ListQuery.Options['filter'],
|
||||
): void {
|
||||
if (typeof filter?.active === 'boolean') {
|
||||
qb.andWhere('workflow.active = :active', { active: filter.active });
|
||||
}
|
||||
}
|
||||
|
||||
private applyTagsFilter(
|
||||
qb: SelectQueryBuilder<WorkflowEntity>,
|
||||
filter: ListQuery.Options['filter'],
|
||||
): void {
|
||||
if (isStringArray(filter?.tags) && filter.tags.length > 0) {
|
||||
const subQuery = qb
|
||||
.subQuery()
|
||||
.select('wt.workflowId')
|
||||
.from(WorkflowTagMapping, 'wt')
|
||||
.innerJoin(TagEntity, 'filter_tags', 'filter_tags.id = wt.tagId')
|
||||
.where('filter_tags.name IN (:...tagNames)', { tagNames: filter.tags })
|
||||
.groupBy('wt.workflowId')
|
||||
.having('COUNT(DISTINCT filter_tags.name) = :tagCount', { tagCount: filter.tags.length });
|
||||
|
||||
qb.andWhere(`workflow.id IN (${subQuery.getQuery()})`).setParameters({
|
||||
tagNames: filter.tags,
|
||||
tagCount: filter.tags.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private applyProjectFilter(
|
||||
qb: SelectQueryBuilder<WorkflowEntity>,
|
||||
filter: ListQuery.Options['filter'],
|
||||
): void {
|
||||
if (typeof filter?.projectId === 'string' && filter.projectId !== '') {
|
||||
qb.innerJoin('workflow.shared', 'shared').andWhere('shared.projectId = :projectId', {
|
||||
projectId: filter.projectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private applyOwnedByRelation(qb: SelectQueryBuilder<WorkflowEntity>): void {
|
||||
// Check if 'shared' join already exists from project filter
|
||||
if (!qb.expressionMap.aliases.find((alias) => alias.name === 'shared')) {
|
||||
qb.leftJoin('workflow.shared', 'shared');
|
||||
}
|
||||
|
||||
// Add the necessary selects
|
||||
qb.addSelect([
|
||||
'shared.role',
|
||||
'shared.createdAt',
|
||||
'shared.updatedAt',
|
||||
'shared.workflowId',
|
||||
'shared.projectId',
|
||||
])
|
||||
.leftJoin('shared.project', 'project')
|
||||
.addSelect([
|
||||
'project.id',
|
||||
'project.name',
|
||||
'project.type',
|
||||
'project.icon',
|
||||
'project.createdAt',
|
||||
'project.updatedAt',
|
||||
]);
|
||||
}
|
||||
|
||||
private applySelect(
|
||||
qb: SelectQueryBuilder<WorkflowEntity>,
|
||||
select?: Record<string, boolean>,
|
||||
): void {
|
||||
// Always start with workflow.id
|
||||
qb.select(['workflow.id']);
|
||||
|
||||
if (!select) {
|
||||
// Default select fields when no select option provided
|
||||
qb.addSelect([
|
||||
'workflow.name',
|
||||
'workflow.active',
|
||||
'workflow.createdAt',
|
||||
'workflow.updatedAt',
|
||||
'workflow.versionId',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle special fields separately
|
||||
const regularFields = Object.entries(select).filter(
|
||||
([field]) => !['ownedBy', 'tags'].includes(field),
|
||||
);
|
||||
|
||||
// Add regular fields
|
||||
regularFields.forEach(([field, include]) => {
|
||||
if (include) {
|
||||
qb.addSelect(`workflow.${field}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private applyRelations(
|
||||
qb: SelectQueryBuilder<WorkflowEntity>,
|
||||
select?: Record<string, boolean>,
|
||||
): void {
|
||||
const areTagsEnabled = !this.globalConfig.tags.disabled;
|
||||
const isDefaultSelect = select === undefined;
|
||||
const areTagsRequested = isDefaultSelect || select?.tags;
|
||||
const isOwnedByIncluded = isDefaultSelect || select?.ownedBy;
|
||||
|
||||
if (areTagsEnabled && areTagsRequested) {
|
||||
this.applyTagsRelation(qb);
|
||||
}
|
||||
|
||||
if (isOwnedByIncluded) {
|
||||
this.applyOwnedByRelation(qb);
|
||||
}
|
||||
}
|
||||
|
||||
private applyTagsRelation(qb: SelectQueryBuilder<WorkflowEntity>): void {
|
||||
qb.leftJoin('workflow.tags', 'tags')
|
||||
.addSelect(['tags.id', 'tags.name'])
|
||||
.addOrderBy('tags.createdAt', 'ASC');
|
||||
}
|
||||
|
||||
private applySorting(qb: SelectQueryBuilder<WorkflowEntity>, sortBy?: string): void {
|
||||
if (!sortBy) {
|
||||
this.applyDefaultSorting(qb);
|
||||
return;
|
||||
}
|
||||
|
||||
const [column, direction] = this.parseSortingParams(sortBy);
|
||||
this.applySortingByColumn(qb, column, direction);
|
||||
}
|
||||
|
||||
private parseSortingParams(sortBy: string): [string, 'ASC' | 'DESC'] {
|
||||
const [column, order] = sortBy.split(':');
|
||||
return [column, order.toUpperCase() as 'ASC' | 'DESC'];
|
||||
}
|
||||
|
||||
private applyDefaultSorting(qb: SelectQueryBuilder<WorkflowEntity>): void {
|
||||
qb.orderBy('workflow.updatedAt', 'ASC');
|
||||
}
|
||||
|
||||
private applySortingByColumn(
|
||||
qb: SelectQueryBuilder<WorkflowEntity>,
|
||||
column: string,
|
||||
direction: 'ASC' | 'DESC',
|
||||
): void {
|
||||
if (column === 'name') {
|
||||
qb.addSelect('LOWER(workflow.name)', 'workflow_name_lower').orderBy(
|
||||
'workflow_name_lower',
|
||||
direction,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
qb.orderBy(`workflow.${column}`, direction);
|
||||
}
|
||||
|
||||
private applyPagination(
|
||||
qb: SelectQueryBuilder<WorkflowEntity>,
|
||||
options: ListQuery.Options,
|
||||
): void {
|
||||
if (options?.take) {
|
||||
qb.skip(options.skip ?? 0).take(options.take);
|
||||
}
|
||||
}
|
||||
|
||||
async findStartingWith(workflowName: string): Promise<Array<{ name: string }>> {
|
||||
return await this.find({
|
||||
select: ['name'],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Container } from '@n8n/di';
|
||||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
|
||||
import type { TagEntity } from '@/databases/entities/tag-entity';
|
||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||
import { TagRepository } from '@/databases/repositories/tag.repository';
|
||||
import { WorkflowTagMappingRepository } from '@/databases/repositories/workflow-tag-mapping.repository';
|
||||
import { generateNanoId } from '@/databases/utils/generators';
|
||||
@@ -25,3 +26,27 @@ export async function createTag(attributes: Partial<TagEntity> = {}, workflow?:
|
||||
|
||||
return tag;
|
||||
}
|
||||
|
||||
export async function assignTagToWorkflow(tag: TagEntity, workflow: WorkflowEntity) {
|
||||
const mappingRepository = Container.get(WorkflowTagMappingRepository);
|
||||
|
||||
// Check if mapping already exists
|
||||
const existingMapping = await mappingRepository.findOne({
|
||||
where: {
|
||||
tagId: tag.id,
|
||||
workflowId: workflow.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingMapping) {
|
||||
return existingMapping;
|
||||
}
|
||||
|
||||
// Create new mapping
|
||||
const mapping = mappingRepository.create({
|
||||
tagId: tag.id,
|
||||
workflowId: workflow.id,
|
||||
});
|
||||
|
||||
return await mappingRepository.save(mapping);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { INode, IPinData, IWorkflowBase } from 'n8n-workflow';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -17,7 +18,7 @@ import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
|
||||
import { mockInstance } from '../../shared/mocking';
|
||||
import { saveCredential } from '../shared/db/credentials';
|
||||
import { createTeamProject, getPersonalProject, linkUserToProject } from '../shared/db/projects';
|
||||
import { createTag } from '../shared/db/tags';
|
||||
import { assignTagToWorkflow, createTag } from '../shared/db/tags';
|
||||
import { createManyUsers, createMember, createOwner } from '../shared/db/users';
|
||||
import {
|
||||
createWorkflow,
|
||||
@@ -645,20 +646,47 @@ describe('GET /workflows', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should filter workflows by field: tags', async () => {
|
||||
const workflow = await createWorkflow({ name: 'First' }, owner);
|
||||
test('should filter workflows by field: tags (AND operator)', async () => {
|
||||
const workflow1 = await createWorkflow({ name: 'First' }, owner);
|
||||
const workflow2 = await createWorkflow({ name: 'Second' }, owner);
|
||||
|
||||
await createTag({ name: 'A' }, workflow);
|
||||
await createTag({ name: 'B' }, workflow);
|
||||
const baseDate = DateTime.now();
|
||||
|
||||
await createTag(
|
||||
{
|
||||
name: 'A',
|
||||
createdAt: baseDate.toJSDate(),
|
||||
},
|
||||
workflow1,
|
||||
);
|
||||
await createTag(
|
||||
{
|
||||
name: 'B',
|
||||
createdAt: baseDate.plus({ seconds: 1 }).toJSDate(),
|
||||
},
|
||||
workflow1,
|
||||
);
|
||||
|
||||
const tagC = await createTag({ name: 'C' }, workflow2);
|
||||
|
||||
await assignTagToWorkflow(tagC, workflow2);
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.get('/workflows')
|
||||
.query('filter={ "tags": ["A"] }')
|
||||
.query('filter={ "tags": ["A", "B"] }')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
count: 1,
|
||||
data: [objectContaining({ name: 'First', tags: [{ id: any(String), name: 'A' }] })],
|
||||
data: [
|
||||
objectContaining({
|
||||
name: 'First',
|
||||
tags: expect.arrayContaining([
|
||||
{ id: any(String), name: 'A' },
|
||||
{ id: any(String), name: 'B' },
|
||||
]),
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -890,27 +918,30 @@ describe('GET /workflows', () => {
|
||||
test('should sort by name column', async () => {
|
||||
await createWorkflow({ name: 'a' }, owner);
|
||||
await createWorkflow({ name: 'b' }, owner);
|
||||
await createWorkflow({ name: 'My workflow' }, owner);
|
||||
|
||||
let response;
|
||||
|
||||
response = await authOwnerAgent.get('/workflows').query('sortBy=name:asc').expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
count: 2,
|
||||
data: arrayContaining([
|
||||
count: 3,
|
||||
data: [
|
||||
expect.objectContaining({ name: 'a' }),
|
||||
expect.objectContaining({ name: 'b' }),
|
||||
]),
|
||||
expect.objectContaining({ name: 'My workflow' }),
|
||||
],
|
||||
});
|
||||
|
||||
response = await authOwnerAgent.get('/workflows').query('sortBy=name:desc').expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
count: 2,
|
||||
data: arrayContaining([
|
||||
count: 3,
|
||||
data: [
|
||||
expect.objectContaining({ name: 'My workflow' }),
|
||||
expect.objectContaining({ name: 'b' }),
|
||||
expect.objectContaining({ name: 'a' }),
|
||||
]),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -944,6 +975,72 @@ describe('GET /workflows', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
beforeEach(async () => {
|
||||
await createWorkflow({ name: 'Workflow 1' }, owner);
|
||||
await createWorkflow({ name: 'Workflow 2' }, owner);
|
||||
await createWorkflow({ name: 'Workflow 3' }, owner);
|
||||
await createWorkflow({ name: 'Workflow 4' }, owner);
|
||||
await createWorkflow({ name: 'Workflow 5' }, owner);
|
||||
});
|
||||
|
||||
test('should fail when skip is provided without take', async () => {
|
||||
await authOwnerAgent.get('/workflows').query('skip=2').expect(500);
|
||||
});
|
||||
|
||||
test('should handle skip with take parameter', async () => {
|
||||
const response = await authOwnerAgent.get('/workflows').query('skip=2&take=2').expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(2);
|
||||
expect(response.body.count).toBe(5);
|
||||
expect(response.body.data[0].name).toBe('Workflow 3');
|
||||
expect(response.body.data[1].name).toBe('Workflow 4');
|
||||
});
|
||||
|
||||
test('should handle pagination with sorting', async () => {
|
||||
const response = await authOwnerAgent
|
||||
.get('/workflows')
|
||||
.query('take=2&skip=1&sortBy=name:desc');
|
||||
|
||||
expect(response.body.data).toHaveLength(2);
|
||||
expect(response.body.count).toBe(5);
|
||||
expect(response.body.data[0].name).toBe('Workflow 4');
|
||||
expect(response.body.data[1].name).toBe('Workflow 3');
|
||||
});
|
||||
|
||||
test('should handle pagination with filtering', async () => {
|
||||
// Create additional workflows with specific names for filtering
|
||||
await createWorkflow({ name: 'Special Workflow 1' }, owner);
|
||||
await createWorkflow({ name: 'Special Workflow 2' }, owner);
|
||||
await createWorkflow({ name: 'Special Workflow 3' }, owner);
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.get('/workflows')
|
||||
.query('take=2&skip=1')
|
||||
.query('filter={"name":"Special"}')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(2);
|
||||
expect(response.body.count).toBe(3); // Only 3 'Special' workflows exist
|
||||
expect(response.body.data[0].name).toBe('Special Workflow 2');
|
||||
expect(response.body.data[1].name).toBe('Special Workflow 3');
|
||||
});
|
||||
|
||||
test('should return empty array when pagination exceeds total count', async () => {
|
||||
const response = await authOwnerAgent.get('/workflows').query('take=2&skip=10').expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(0);
|
||||
expect(response.body.count).toBe(5);
|
||||
});
|
||||
|
||||
test('should return all results when no pagination parameters are provided', async () => {
|
||||
const response = await authOwnerAgent.get('/workflows').expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(5);
|
||||
expect(response.body.count).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /workflows/:workflowId', () => {
|
||||
|
||||
Reference in New Issue
Block a user