refactor(core): Update workflowRepository.getMany to use query builder (no-changelog) (#13207)

This commit is contained in:
Ricardo Espinoza
2025-02-17 08:59:42 -05:00
committed by GitHub
parent d1e65a1cd5
commit 5b82f34773
3 changed files with 350 additions and 96 deletions

View File

@@ -1,22 +1,22 @@
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { import { DataSource, Repository, In, Like } from '@n8n/typeorm';
DataSource, import type {
Repository, SelectQueryBuilder,
In, UpdateResult,
Like, FindOptionsWhere,
type UpdateResult, FindOptionsSelect,
type FindOptionsWhere, FindManyOptions,
type FindOptionsSelect, FindOptionsRelations,
type FindManyOptions,
type FindOptionsRelations,
} from '@n8n/typeorm'; } from '@n8n/typeorm';
import type { ListQuery } from '@/requests'; import type { ListQuery } from '@/requests';
import { isStringArray } from '@/utils'; import { isStringArray } from '@/utils';
import { TagEntity } from '../entities/tag-entity';
import { WebhookEntity } from '../entities/webhook-entity'; import { WebhookEntity } from '../entities/webhook-entity';
import { WorkflowEntity } from '../entities/workflow-entity'; import { WorkflowEntity } from '../entities/workflow-entity';
import { WorkflowTagMapping } from '../entities/workflow-tag-mapping';
@Service() @Service()
export class WorkflowRepository extends Repository<WorkflowEntity> { export class WorkflowRepository extends Repository<WorkflowEntity> {
@@ -99,83 +99,20 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
.execute(); .execute();
} }
async getMany(sharedWorkflowIds: string[], originalOptions: ListQuery.Options = {}) { async getMany(sharedWorkflowIds: string[], options: ListQuery.Options = {}) {
const options = structuredClone(originalOptions); if (sharedWorkflowIds.length === 0) {
if (sharedWorkflowIds.length === 0) return { workflows: [], count: 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;
} }
const where: FindOptionsWhere<WorkflowEntity> = { const qb = this.createBaseQuery(sharedWorkflowIds);
...options?.filter,
id: In(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)) { const [workflows, count] = (await qb.getManyAndCount()) as [
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 [
ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[], ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[],
number, number,
]; ];
@@ -183,6 +120,201 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
return { workflows, count }; 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 }>> { async findStartingWith(workflowName: string): Promise<Array<{ name: string }>> {
return await this.find({ return await this.find({
select: ['name'], select: ['name'],

View File

@@ -2,6 +2,7 @@ import { Container } from '@n8n/di';
import type { IWorkflowBase } from 'n8n-workflow'; import type { IWorkflowBase } from 'n8n-workflow';
import type { TagEntity } from '@/databases/entities/tag-entity'; import type { TagEntity } from '@/databases/entities/tag-entity';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import { TagRepository } from '@/databases/repositories/tag.repository'; import { TagRepository } from '@/databases/repositories/tag.repository';
import { WorkflowTagMappingRepository } from '@/databases/repositories/workflow-tag-mapping.repository'; import { WorkflowTagMappingRepository } from '@/databases/repositories/workflow-tag-mapping.repository';
import { generateNanoId } from '@/databases/utils/generators'; import { generateNanoId } from '@/databases/utils/generators';
@@ -25,3 +26,27 @@ export async function createTag(attributes: Partial<TagEntity> = {}, workflow?:
return tag; 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);
}

View File

@@ -1,5 +1,6 @@
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions';
import { DateTime } from 'luxon';
import type { INode, IPinData, IWorkflowBase } from 'n8n-workflow'; import type { INode, IPinData, IWorkflowBase } from 'n8n-workflow';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
@@ -17,7 +18,7 @@ import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
import { mockInstance } from '../../shared/mocking'; import { mockInstance } from '../../shared/mocking';
import { saveCredential } from '../shared/db/credentials'; import { saveCredential } from '../shared/db/credentials';
import { createTeamProject, getPersonalProject, linkUserToProject } from '../shared/db/projects'; 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 { createManyUsers, createMember, createOwner } from '../shared/db/users';
import { import {
createWorkflow, createWorkflow,
@@ -645,20 +646,47 @@ describe('GET /workflows', () => {
}); });
}); });
test('should filter workflows by field: tags', async () => { test('should filter workflows by field: tags (AND operator)', async () => {
const workflow = await createWorkflow({ name: 'First' }, owner); const workflow1 = await createWorkflow({ name: 'First' }, owner);
const workflow2 = await createWorkflow({ name: 'Second' }, owner);
await createTag({ name: 'A' }, workflow); const baseDate = DateTime.now();
await createTag({ name: 'B' }, workflow);
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 const response = await authOwnerAgent
.get('/workflows') .get('/workflows')
.query('filter={ "tags": ["A"] }') .query('filter={ "tags": ["A", "B"] }')
.expect(200); .expect(200);
expect(response.body).toEqual({ expect(response.body).toEqual({
count: 1, 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 () => { test('should sort by name column', async () => {
await createWorkflow({ name: 'a' }, owner); await createWorkflow({ name: 'a' }, owner);
await createWorkflow({ name: 'b' }, owner); await createWorkflow({ name: 'b' }, owner);
await createWorkflow({ name: 'My workflow' }, owner);
let response; let response;
response = await authOwnerAgent.get('/workflows').query('sortBy=name:asc').expect(200); response = await authOwnerAgent.get('/workflows').query('sortBy=name:asc').expect(200);
expect(response.body).toEqual({ expect(response.body).toEqual({
count: 2, count: 3,
data: arrayContaining([ data: [
expect.objectContaining({ name: 'a' }), expect.objectContaining({ name: 'a' }),
expect.objectContaining({ name: 'b' }), expect.objectContaining({ name: 'b' }),
]), expect.objectContaining({ name: 'My workflow' }),
],
}); });
response = await authOwnerAgent.get('/workflows').query('sortBy=name:desc').expect(200); response = await authOwnerAgent.get('/workflows').query('sortBy=name:desc').expect(200);
expect(response.body).toEqual({ expect(response.body).toEqual({
count: 2, count: 3,
data: arrayContaining([ data: [
expect.objectContaining({ name: 'My workflow' }),
expect.objectContaining({ name: 'b' }), expect.objectContaining({ name: 'b' }),
expect.objectContaining({ name: 'a' }), 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', () => { describe('PATCH /workflows/:workflowId', () => {