diff --git a/packages/cli/src/databases/repositories/workflow.repository.ts b/packages/cli/src/databases/repositories/workflow.repository.ts index 1fd66266c6..8e750ba2f5 100644 --- a/packages/cli/src/databases/repositories/workflow.repository.ts +++ b/packages/cli/src/databases/repositories/workflow.repository.ts @@ -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 { @@ -99,83 +99,20 @@ export class WorkflowRepository extends Repository { .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 = { - ...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 & { 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 = { - 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 { return { workflows, count }; } + private createBaseQuery(sharedWorkflowIds: string[]): SelectQueryBuilder { + return this.createQueryBuilder('workflow').where('workflow.id IN (:...sharedWorkflowIds)', { + sharedWorkflowIds, + }); + } + + private applyFilters( + qb: SelectQueryBuilder, + 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, + 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, + filter: ListQuery.Options['filter'], + ): void { + if (typeof filter?.active === 'boolean') { + qb.andWhere('workflow.active = :active', { active: filter.active }); + } + } + + private applyTagsFilter( + qb: SelectQueryBuilder, + 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, + 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): 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, + select?: Record, + ): 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, + select?: Record, + ): 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): void { + qb.leftJoin('workflow.tags', 'tags') + .addSelect(['tags.id', 'tags.name']) + .addOrderBy('tags.createdAt', 'ASC'); + } + + private applySorting(qb: SelectQueryBuilder, 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): void { + qb.orderBy('workflow.updatedAt', 'ASC'); + } + + private applySortingByColumn( + qb: SelectQueryBuilder, + 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, + options: ListQuery.Options, + ): void { + if (options?.take) { + qb.skip(options.skip ?? 0).take(options.take); + } + } + async findStartingWith(workflowName: string): Promise> { return await this.find({ select: ['name'], diff --git a/packages/cli/test/integration/shared/db/tags.ts b/packages/cli/test/integration/shared/db/tags.ts index 1216c9b445..3dd851ef4f 100644 --- a/packages/cli/test/integration/shared/db/tags.ts +++ b/packages/cli/test/integration/shared/db/tags.ts @@ -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 = {}, 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); +} diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 2f92b1776c..fd79910f4e 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -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', () => {