diff --git a/packages/cli/src/databases/entities/folder-tag-mapping.ts b/packages/cli/src/databases/entities/folder-tag-mapping.ts new file mode 100644 index 0000000000..edd78d077d --- /dev/null +++ b/packages/cli/src/databases/entities/folder-tag-mapping.ts @@ -0,0 +1,21 @@ +import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; + +import type { Folder } from './folder'; +import type { TagEntity } from './tag-entity'; + +@Entity({ name: 'folder_tag' }) +export class FolderTagMapping { + @PrimaryColumn() + folderId: string; + + @ManyToOne('Folder', 'tagMappings') + @JoinColumn({ name: 'folderId' }) + folders: Folder[]; + + @PrimaryColumn() + tagId: string; + + @ManyToOne('TagEntity', 'folderMappings') + @JoinColumn({ name: 'tagId' }) + tags: TagEntity[]; +} diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 14af478d4c..3412f72a8f 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -11,6 +11,7 @@ import { ExecutionData } from './execution-data'; import { ExecutionEntity } from './execution-entity'; import { ExecutionMetadata } from './execution-metadata'; import { Folder } from './folder'; +import { FolderTagMapping } from './folder-tag-mapping'; import { InstalledNodes } from './installed-nodes'; import { InstalledPackages } from './installed-packages'; import { InvalidAuthToken } from './invalid-auth-token'; @@ -68,4 +69,5 @@ export const entities = { TestRun, TestCaseExecution, Folder, + FolderTagMapping, }; diff --git a/packages/cli/src/databases/entities/tag-entity.ts b/packages/cli/src/databases/entities/tag-entity.ts index 0cf9a4ffa1..b0d539af17 100644 --- a/packages/cli/src/databases/entities/tag-entity.ts +++ b/packages/cli/src/databases/entities/tag-entity.ts @@ -2,6 +2,7 @@ import { Column, Entity, Index, ManyToMany, OneToMany } from '@n8n/typeorm'; import { IsString, Length } from 'class-validator'; import { WithTimestampsAndStringId } from './abstract-entity'; +import type { FolderTagMapping } from './folder-tag-mapping'; import type { WorkflowEntity } from './workflow-entity'; import type { WorkflowTagMapping } from './workflow-tag-mapping'; @@ -18,4 +19,7 @@ export class TagEntity extends WithTimestampsAndStringId { @OneToMany('WorkflowTagMapping', 'tags') workflowMappings: WorkflowTagMapping[]; + + @OneToMany('FolderTagMapping', 'tags') + folderMappings: FolderTagMapping[]; } diff --git a/packages/cli/src/databases/repositories/__tests__/folder.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/folder.repository.test.ts new file mode 100644 index 0000000000..256ba5e6df --- /dev/null +++ b/packages/cli/src/databases/repositories/__tests__/folder.repository.test.ts @@ -0,0 +1,600 @@ +import { Container } from '@n8n/di'; +import { DateTime } from 'luxon'; + +import type { Folder } from '@/databases/entities/folder'; +import type { Project } from '@/databases/entities/project'; +import type { User } from '@/databases/entities/user'; +import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; +import { createFolder } from '@test-integration/db/folders'; +import { getPersonalProject } from '@test-integration/db/projects'; +import { createTag } from '@test-integration/db/tags'; +import { createMember, createOwner } from '@test-integration/db/users'; +import { createWorkflow } from '@test-integration/db/workflows'; + +import * as testDb from '../../../../test/integration/shared/test-db'; +import { FolderRepository } from '../folder.repository'; + +describe('FolderRepository', () => { + let folderRepository: FolderRepository; + + beforeAll(async () => { + await testDb.init(); + folderRepository = Container.get(FolderRepository); + }); + + afterEach(async () => { + await testDb.truncate(['Folder', 'Tag']); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + describe('getMany', () => { + let project: Project; + let owner: User; + + beforeAll(async () => { + owner = await createOwner(); + project = await getPersonalProject(owner); + }); + + describe('filters', () => { + it('should return all folders if not filter is provided', async () => { + const folder1 = await createFolder(project, { name: 'folder1' }); + const folder2 = await createFolder(project, { name: 'folder2' }); + + await Promise.all([folder1, folder2]); + + const [folders, count] = await folderRepository.getMany(); + expect(count).toBe(2); + expect(folders).toHaveLength(2); + + expect(folders[1].name).toBe('folder1'); + expect(folders[0].name).toBe('folder2'); + + folders.forEach((folder) => { + expect(folder).toMatchObject({ + id: expect.any(String), + name: expect.any(String), + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + parentFolder: null, + project: { + id: expect.any(String), + name: expect.any(String), + type: expect.any(String), + icon: null, + }, + tags: expect.any(Array), + }); + }); + }); + it('should filter folders by project ID', async () => { + const anotherUser = await createMember(); + const anotherProject = await getPersonalProject(anotherUser); + + const folder1 = createFolder(project, { name: 'folder1' }); + const folder2 = createFolder(anotherProject, { name: 'folder2' }); + + await Promise.all([folder1, folder2]); + + const [folders, count] = await folderRepository.getMany({ + filter: { projectId: project.id }, + }); + + expect(count).toBe(1); + expect(folders).toHaveLength(1); + expect(folders[0].name).toBe('folder1'); + expect(folders[0].project.id).toBe(project.id); + }); + + it('should filter folders by name case-insensitively', async () => { + const folder1 = createFolder(project, { name: 'Test Folder' }); + const folder2 = createFolder(project, { name: 'Another Folder' }); + const folder3 = createFolder(project, { name: 'test folder sub' }); + + await Promise.all([folder1, folder2, folder3]); + + const [folders, count] = await folderRepository.getMany({ + filter: { name: 'test' }, + }); + + expect(count).toBe(2); + expect(folders).toHaveLength(2); + expect(folders.map((f) => f.name).sort()).toEqual(['Test Folder', 'test folder sub']); + }); + + it('should filter folders by parent folder ID', async () => { + const parentFolder = await createFolder(project, { name: 'Parent' }); + await createFolder(project, { + name: 'Child 1', + parentFolder, + }); + await createFolder(project, { + name: 'Child 2', + parentFolder, + }); + await createFolder(project, { name: 'Unrelated' }); + + const [folders, count] = await folderRepository.getMany({ + filter: { parentFolderId: parentFolder.id }, + }); + + expect(count).toBe(2); + expect(folders).toHaveLength(2); + expect(folders.map((f) => f.name).sort()).toEqual(['Child 1', 'Child 2']); + folders.forEach((folder) => { + expect(folder.parentFolder?.id).toBe(parentFolder.id); + }); + }); + + it('should filter folders by a single tag', async () => { + const tag1 = await createTag({ name: 'important' }); + const tag2 = await createTag({ name: 'archived' }); + + await createFolder(project, { + name: 'Folder 1', + tags: [tag1], + }); + + await createFolder(project, { + name: 'Folder 2', + tags: [tag2], + }); + + const [folders, count] = await folderRepository.getMany({ + filter: { tags: ['important'] }, + }); + + expect(count).toBe(1); + expect(folders).toHaveLength(1); + expect(folders[0].name).toBe('Folder 1'); + expect(folders[0].tags[0].name).toBe('important'); + }); + + it('should filter folders by multiple tags (AND operator)', async () => { + const tag1 = await createTag({ name: 'important' }); + const tag2 = await createTag({ name: 'active' }); + const tag3 = await createTag({ name: 'archived' }); + + await createFolder(project, { + name: 'Folder 1', + tags: [tag1, tag2], + }); + await createFolder(project, { + name: 'Folder 2', + tags: [tag1], + }); + await createFolder(project, { + name: 'Folder 3', + tags: [tag3], + }); + + const [folders] = await folderRepository.getMany({ + filter: { tags: ['important', 'active'] }, + }); + + expect(folders).toHaveLength(1); + expect(folders[0].name).toBe('Folder 1'); + }); + + it('should apply multiple filters together', async () => { + const tag1 = await createTag({ name: 'important' }); + const tag2 = await createTag({ name: 'archived' }); + + const parentFolder = await createFolder(project, { name: 'Parent' }); + await createFolder(project, { + name: 'Test Folder', + parentFolder, + tags: [tag1], + }); + await createFolder(project, { + name: 'Test Another', + tags: [tag1], + }); + await createFolder(project, { + name: 'Test Child', + parentFolder, + tags: [tag2], + }); + + const [folders, count] = await folderRepository.getMany({ + filter: { + name: 'test', + parentFolderId: parentFolder.id, + tags: ['important'], + }, + }); + + expect(count).toBe(1); + expect(folders).toHaveLength(1); + expect(folders[0].name).toBe('Test Folder'); + expect(folders[0].parentFolder?.id).toBe(parentFolder.id); + expect(folders[0].tags[0].name).toBe('important'); + }); + }); + + describe('select', () => { + let testFolder: Folder; + let workflowWithTestFolder: WorkflowEntity; + + beforeEach(async () => { + const parentFolder = await createFolder(project, { name: 'Parent Folder' }); + const tag = await createTag({ name: 'test-tag' }); + testFolder = await createFolder(project, { + name: 'Test Folder', + parentFolder, + tags: [tag], + }); + workflowWithTestFolder = await createWorkflow({ parentFolder: testFolder }); + }); + + it('should select only id and name when specified', async () => { + const [folders] = await folderRepository.getMany({ + select: { + id: true, + name: true, + }, + }); + + expect(folders).toEqual([ + { + id: expect.any(String), + name: 'Test Folder', + }, + { + id: expect.any(String), + name: 'Parent Folder', + }, + ]); + }); + + it('should return id, name and tags when specified', async () => { + const [folders] = await folderRepository.getMany({ + select: { + id: true, + name: true, + tags: true, + }, + }); + + expect(folders).toHaveLength(2); + folders.forEach((folder) => { + expect(Object.keys(folder).sort()).toEqual(['id', 'name', 'tags']); + expect(folder.id).toBeDefined(); + expect(folder.name).toBeDefined(); + expect(Array.isArray(folder.tags)).toBeTruthy(); + }); + + const folderWithTag = folders.find((f) => f.name === 'Test Folder'); + expect(folderWithTag?.tags).toHaveLength(1); + expect(folderWithTag?.tags[0]).toEqual({ + id: expect.any(String), + name: 'test-tag', + }); + }); + + it('should return id, name and project when specified', async () => { + const [folders] = await folderRepository.getMany({ + select: { + id: true, + name: true, + project: true, + }, + }); + + expect(folders).toHaveLength(2); + folders.forEach((folder) => { + expect(Object.keys(folder).sort()).toEqual(['id', 'name', 'project']); + expect(folder.project).toEqual({ + id: expect.any(String), + name: expect.any(String), + type: expect.any(String), + icon: null, + }); + }); + }); + + it('should return id, name and parentFolder when specified', async () => { + const [folders] = await folderRepository.getMany({ + select: { + id: true, + name: true, + parentFolder: true, + }, + }); + + expect(folders).toHaveLength(2); + folders.forEach((folder) => { + expect(Object.keys(folder).sort()).toEqual(['id', 'name', 'parentFolder']); + }); + + const parentFolder = folders.find((f) => f.name === 'Parent Folder'); + expect(parentFolder?.parentFolder).toBeNull(); + + const childFolder = folders.find((f) => f.name === 'Test Folder'); + expect(childFolder?.parentFolder).toEqual({ + id: expect.any(String), + name: 'Parent Folder', + }); + }); + + it('should return id, name and workflows when specified', async () => { + const [folders] = await folderRepository.getMany({ + select: { + id: true, + name: true, + workflows: true, + }, + }); + + expect(folders).toHaveLength(2); + folders.forEach((folder) => { + expect(Object.keys(folder).sort()).toEqual(['id', 'name', 'workflows']); + expect(folder.id).toBeDefined(); + expect(folder.name).toBeDefined(); + expect(Array.isArray(folder.workflows)).toBeTruthy(); + }); + + expect(folders[0].workflows).toHaveLength(1); + expect(folders[0].workflows[0].id).toBe(workflowWithTestFolder.id); + }); + + it('should return timestamps when specified', async () => { + const [folders] = await folderRepository.getMany({ + select: { + id: true, + createdAt: true, + updatedAt: true, + }, + }); + + expect(folders).toHaveLength(2); + folders.forEach((folder) => { + expect(Object.keys(folder).sort()).toEqual(['createdAt', 'id', 'updatedAt']); + expect(folder.createdAt).toBeInstanceOf(Date); + expect(folder.updatedAt).toBeInstanceOf(Date); + }); + }); + + it('should return all properties when no select is specified', async () => { + const [folders] = await folderRepository.getMany(); + + expect(folders).toHaveLength(2); + folders.forEach((folder) => { + expect(folder).toMatchObject({ + id: expect.any(String), + name: expect.any(String), + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + project: { + id: expect.any(String), + name: expect.any(String), + type: expect.any(String), + icon: null, + }, + workflows: expect.any(Array), + tags: expect.any(Array), + }); + }); + }); + }); + + describe('pagination', () => { + beforeEach(async () => { + // Create 5 folders sequentially and ensure consistent updatedAt order + await createFolder(project, { + name: 'Folder 1', + updatedAt: DateTime.now().minus({ minutes: 4 }).toJSDate(), + }); + await createFolder(project, { + name: 'Folder 2', + updatedAt: DateTime.now().minus({ minutes: 3 }).toJSDate(), + }); + await createFolder(project, { + name: 'Folder 3', + updatedAt: DateTime.now().minus({ minutes: 2 }).toJSDate(), + }); + await createFolder(project, { + name: 'Folder 4', + updatedAt: DateTime.now().minus({ minutes: 1 }).toJSDate(), + }); + + await createFolder(project, { + name: 'Folder 5', + updatedAt: DateTime.now().toJSDate(), + }); + }); + + it('should limit results when take is specified', async () => { + const [folders, count] = await folderRepository.getMany({ + take: 3, + }); + + expect(count).toBe(5); + expect(folders).toHaveLength(3); + }); + + it('should skip results when skip is specified', async () => { + const [folders, count] = await folderRepository.getMany({ + skip: 2, + take: 5, + }); + + expect(count).toBe(5); + expect(folders).toHaveLength(3); + expect(folders.map((f) => f.name)).toEqual(['Folder 3', 'Folder 2', 'Folder 1']); + }); + + it('should handle skip and take together', async () => { + const [folders, count] = await folderRepository.getMany({ + skip: 1, + take: 2, + }); + + expect(count).toBe(5); + expect(folders).toHaveLength(2); + expect(folders.map((f) => f.name)).toEqual(['Folder 4', 'Folder 3']); + }); + + it('should handle take larger than remaining items', async () => { + const [folders, count] = await folderRepository.getMany({ + skip: 3, + take: 10, + }); + + expect(count).toBe(5); + expect(folders).toHaveLength(2); + expect(folders.map((f) => f.name)).toEqual(['Folder 2', 'Folder 1']); + }); + + it('should handle zero take by returning all results', async () => { + const [folders, count] = await folderRepository.getMany({ + take: 0, + }); + + expect(count).toBe(5); + expect(folders).toHaveLength(5); + expect(folders.map((f) => f.name)).toEqual([ + 'Folder 5', + 'Folder 4', + 'Folder 3', + 'Folder 2', + 'Folder 1', + ]); + }); + }); + + describe('sorting', () => { + beforeEach(async () => { + // Create 4 folders sequentially and ensure consistent updatedAt order + await createFolder(project, { + name: 'B Folder', + createdAt: DateTime.now().toJSDate(), + updatedAt: DateTime.now().toJSDate(), + }); + + await createFolder(project, { + name: 'A Folder', + createdAt: DateTime.now().plus({ minutes: 1 }).toJSDate(), + updatedAt: DateTime.now().plus({ minutes: 1 }).toJSDate(), + }); + + await createFolder(project, { + name: 'D Folder', + createdAt: DateTime.now().plus({ minutes: 2 }).toJSDate(), + updatedAt: DateTime.now().plus({ minutes: 2 }).toJSDate(), + }); + + await createFolder(project, { + name: 'C Folder', + createdAt: DateTime.now().plus({ minutes: 3 }).toJSDate(), + updatedAt: DateTime.now().plus({ minutes: 3 }).toJSDate(), + }); + }); + + it('should sort by default (updatedAt:desc)', async () => { + const [folders] = await folderRepository.getMany(); + + expect(folders.map((f) => f.name)).toEqual([ + 'C Folder', + 'D Folder', + 'A Folder', + 'B Folder', + ]); + }); + + it('should sort by name:asc', async () => { + const [folders] = await folderRepository.getMany({ + sortBy: 'name:asc', + }); + + expect(folders.map((f) => f.name)).toEqual([ + 'A Folder', + 'B Folder', + 'C Folder', + 'D Folder', + ]); + }); + + it('should sort by name:desc', async () => { + const [folders] = await folderRepository.getMany({ + sortBy: 'name:desc', + }); + + expect(folders.map((f) => f.name)).toEqual([ + 'D Folder', + 'C Folder', + 'B Folder', + 'A Folder', + ]); + }); + + it('should sort by createdAt:asc', async () => { + const [folders] = await folderRepository.getMany({ + sortBy: 'createdAt:asc', + }); + + expect(folders.map((f) => f.name)).toEqual([ + 'B Folder', + 'A Folder', + 'D Folder', + 'C Folder', + ]); + }); + + it('should sort by createdAt:desc', async () => { + const [folders] = await folderRepository.getMany({ + sortBy: 'createdAt:desc', + }); + + expect(folders.map((f) => f.name)).toEqual([ + 'C Folder', + 'D Folder', + 'A Folder', + 'B Folder', + ]); + }); + + it('should sort by updatedAt:asc', async () => { + const [folders] = await folderRepository.getMany({ + sortBy: 'updatedAt:asc', + }); + + expect(folders.map((f) => f.name)).toEqual([ + 'B Folder', + 'A Folder', + 'D Folder', + 'C Folder', + ]); + }); + + it('should sort by updatedAt:desc', async () => { + const [folders] = await folderRepository.getMany({ + sortBy: 'updatedAt:desc', + }); + + expect(folders.map((f) => f.name)).toEqual([ + 'C Folder', + 'D Folder', + 'A Folder', + 'B Folder', + ]); + }); + + it('should default to asc if order not specified', async () => { + const [folders] = await folderRepository.getMany({ + sortBy: 'name', + }); + + expect(folders.map((f) => f.name)).toEqual([ + 'A Folder', + 'B Folder', + 'C Folder', + 'D Folder', + ]); + }); + }); + }); +}); diff --git a/packages/cli/src/databases/repositories/folder.repository.ts b/packages/cli/src/databases/repositories/folder.repository.ts index 7f1143b83c..ae8c5d4d6f 100644 --- a/packages/cli/src/databases/repositories/folder.repository.ts +++ b/packages/cli/src/databases/repositories/folder.repository.ts @@ -1,11 +1,200 @@ import { Service } from '@n8n/di'; +import type { SelectQueryBuilder } from '@n8n/typeorm'; import { DataSource, Repository } from '@n8n/typeorm'; +import type { ListQuery } from '@/requests'; + import { Folder } from '../entities/folder'; +import { FolderTagMapping } from '../entities/folder-tag-mapping'; +import { TagEntity } from '../entities/tag-entity'; @Service() export class FolderRepository extends Repository { constructor(dataSource: DataSource) { super(Folder, dataSource.manager); } + + async getMany(options: ListQuery.Options = {}): Promise<[Folder[], number]> { + const query = this.createQueryBuilder('folder'); + + this.applySelections(query, options.select); + this.applyFilters(query, options.filter); + this.applySorting(query, options.sortBy); + this.applyPagination(query, options); + + return await query.getManyAndCount(); + } + + private applySelections( + query: SelectQueryBuilder, + select?: Record, + ): void { + if (select) { + this.applyCustomSelect(query, select); + } else { + this.applyDefaultSelect(query); + } + } + + private applyDefaultSelect(query: SelectQueryBuilder): void { + query + .leftJoinAndSelect('folder.project', 'project') + .leftJoinAndSelect('folder.parentFolder', 'parentFolder') + .leftJoinAndSelect('folder.tags', 'tags') + .leftJoinAndSelect('folder.workflows', 'workflows') + .select([ + 'folder', + ...this.getProjectFields('project'), + ...this.getTagFields(), + ...this.getParentFolderFields('parentFolder'), + 'workflows.id', + ]); + } + + private applyCustomSelect( + query: SelectQueryBuilder, + select?: Record, + ): void { + const selections = ['folder.id']; + + this.addBasicFields(selections, select); + this.addRelationFields(query, selections, select); + + query.select(selections); + } + + private addBasicFields(selections: string[], select?: Record): void { + if (select?.name) selections.push('folder.name'); + if (select?.createdAt) selections.push('folder.createdAt'); + if (select?.updatedAt) selections.push('folder.updatedAt'); + } + + private addRelationFields( + query: SelectQueryBuilder, + selections: string[], + select?: Record, + ): void { + if (select?.project) { + query.leftJoin('folder.project', 'project'); + selections.push(...this.getProjectFields('project')); + } + + if (select?.tags) { + query.leftJoin('folder.tags', 'tags').addOrderBy('tags.createdAt', 'ASC'); + selections.push(...this.getTagFields()); + } + + if (select?.parentFolder) { + query.leftJoin('folder.parentFolder', 'parentFolder'); + selections.push(...this.getParentFolderFields('parentFolder')); + } + + if (select?.workflows) { + query.leftJoinAndSelect('folder.workflows', 'workflows'); + selections.push('workflows.id'); + } + } + + private getProjectFields(alias: string): string[] { + return [`${alias}.id`, `${alias}.name`, `${alias}.type`, `${alias}.icon`]; + } + + private getTagFields(): string[] { + return ['tags.id', 'tags.name']; + } + + private getParentFolderFields(alias: string): string[] { + return [`${alias}.id`, `${alias}.name`]; + } + + private applyFilters( + query: SelectQueryBuilder, + filter?: ListQuery.Options['filter'], + ): void { + if (!filter) return; + + this.applyBasicFilters(query, filter); + this.applyTagsFilter(query, Array.isArray(filter?.tags) ? filter.tags : undefined); + } + + private applyBasicFilters( + query: SelectQueryBuilder, + filter: ListQuery.Options['filter'], + ): void { + if (filter?.projectId) { + query.andWhere('folder.projectId = :projectId', { projectId: filter.projectId }); + } + + if (filter?.name && typeof filter.name === 'string') { + query.andWhere('LOWER(folder.name) LIKE LOWER(:name)', { + name: `%${filter.name}%`, + }); + } + + if (filter?.parentFolderId) { + query.andWhere('folder.parentFolderId = :parentFolderId', { + parentFolderId: filter.parentFolderId, + }); + } + } + + private applyTagsFilter(query: SelectQueryBuilder, tags?: string[]): void { + if (!Array.isArray(tags) || tags.length === 0) return; + + const subQuery = this.createTagsSubQuery(query, tags); + + query.andWhere(`folder.id IN (${subQuery.getQuery()})`).setParameters({ + tagNames: tags, + tagCount: tags.length, + }); + } + + private createTagsSubQuery( + query: SelectQueryBuilder, + tags: string[], + ): SelectQueryBuilder { + return query + .subQuery() + .select('ft.folderId') + .from(FolderTagMapping, 'ft') + .innerJoin(TagEntity, 'filter_tags', 'filter_tags.id = ft.tagId') + .where('filter_tags.name IN (:...tagNames)', { tagNames: tags }) + .groupBy('ft.folderId') + .having('COUNT(DISTINCT filter_tags.name) = :tagCount', { + tagCount: tags.length, + }); + } + + private applySorting(query: SelectQueryBuilder, sortBy?: string): void { + if (!sortBy) { + query.orderBy('folder.updatedAt', 'DESC'); + return; + } + + const [field, order] = this.parseSortingParams(sortBy); + this.applySortingByField(query, field, order); + } + + private parseSortingParams(sortBy: string): [string, 'DESC' | 'ASC'] { + const [field, order] = sortBy.split(':'); + return [field, order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC']; + } + + private applySortingByField( + query: SelectQueryBuilder, + field: string, + direction: 'DESC' | 'ASC', + ): void { + if (field === 'name') { + query.orderBy('LOWER(folder.name)', direction); + } else if (['createdAt', 'updatedAt'].includes(field)) { + query.orderBy(`folder.${field}`, direction); + } + } + + private applyPagination(query: SelectQueryBuilder, options: ListQuery.Options): void { + if (options?.take) { + query.skip(options.skip ?? 0).take(options.take); + } + } } diff --git a/packages/cli/src/interfaces.ts b/packages/cli/src/interfaces.ts index b7e51525c1..d5a7cbf341 100644 --- a/packages/cli/src/interfaces.ts +++ b/packages/cli/src/interfaces.ts @@ -33,6 +33,7 @@ import type { TagEntity } from '@/databases/entities/tag-entity'; import type { AssignableRole, GlobalRole, User } from '@/databases/entities/user'; import type { LICENSE_FEATURES, LICENSE_QUOTAS } from './constants'; +import type { Folder } from './databases/entities/folder'; import type { ExternalHooks } from './external-hooks'; import type { WorkflowWithSharingsAndCredentials } from './workflows/workflows.types'; @@ -92,6 +93,7 @@ export type IAnnotationTagWithCountDb = IAnnotationTagDb & UsageCount; export interface IWorkflowDb extends IWorkflowBase { triggerCount: number; tags?: TagEntity[]; + parentFolder?: Folder | null; } export interface IWorkflowToImport extends IWorkflowBase { diff --git a/packages/cli/test/integration/shared/db/folders.ts b/packages/cli/test/integration/shared/db/folders.ts new file mode 100644 index 0000000000..1858c81f91 --- /dev/null +++ b/packages/cli/test/integration/shared/db/folders.ts @@ -0,0 +1,32 @@ +import { Container } from '@n8n/di'; + +import type { Folder } from '@/databases/entities/folder'; +import type { Project } from '@/databases/entities/project'; +import type { TagEntity } from '@/databases/entities/tag-entity'; +import { FolderRepository } from '@/databases/repositories/folder.repository'; +import { randomName } from '@test-integration/random'; + +export const createFolder = async ( + project: Project, + options: { + name?: string; + parentFolder?: Folder; + tags?: TagEntity[]; + updatedAt?: Date; + createdAt?: Date; + } = {}, +) => { + const folderRepository = Container.get(FolderRepository); + const folder = await folderRepository.save( + folderRepository.create({ + name: options.name ?? randomName(), + project, + parentFolder: options.parentFolder ?? null, + tags: options.tags ?? [], + updatedAt: options.updatedAt ?? new Date(), + createdAt: options.updatedAt ?? new Date(), + }), + ); + + return folder; +}; diff --git a/packages/cli/test/integration/shared/test-db.ts b/packages/cli/test/integration/shared/test-db.ts index 052d383c27..495f64a15a 100644 --- a/packages/cli/test/integration/shared/test-db.ts +++ b/packages/cli/test/integration/shared/test-db.ts @@ -85,6 +85,7 @@ const repositories = [ 'WorkflowStatistics', 'WorkflowTagMapping', 'ApiKey', + 'Folder', ] as const; /**