mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(core): Add method (getMany) to folder repository to filter, sort, and paginate folders (no-changelog) (#13290)
This commit is contained in:
21
packages/cli/src/databases/entities/folder-tag-mapping.ts
Normal file
21
packages/cli/src/databases/entities/folder-tag-mapping.ts
Normal file
@@ -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[];
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { ExecutionData } from './execution-data';
|
|||||||
import { ExecutionEntity } from './execution-entity';
|
import { ExecutionEntity } from './execution-entity';
|
||||||
import { ExecutionMetadata } from './execution-metadata';
|
import { ExecutionMetadata } from './execution-metadata';
|
||||||
import { Folder } from './folder';
|
import { Folder } from './folder';
|
||||||
|
import { FolderTagMapping } from './folder-tag-mapping';
|
||||||
import { InstalledNodes } from './installed-nodes';
|
import { InstalledNodes } from './installed-nodes';
|
||||||
import { InstalledPackages } from './installed-packages';
|
import { InstalledPackages } from './installed-packages';
|
||||||
import { InvalidAuthToken } from './invalid-auth-token';
|
import { InvalidAuthToken } from './invalid-auth-token';
|
||||||
@@ -68,4 +69,5 @@ export const entities = {
|
|||||||
TestRun,
|
TestRun,
|
||||||
TestCaseExecution,
|
TestCaseExecution,
|
||||||
Folder,
|
Folder,
|
||||||
|
FolderTagMapping,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Column, Entity, Index, ManyToMany, OneToMany } from '@n8n/typeorm';
|
|||||||
import { IsString, Length } from 'class-validator';
|
import { IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
import { WithTimestampsAndStringId } from './abstract-entity';
|
import { WithTimestampsAndStringId } from './abstract-entity';
|
||||||
|
import type { FolderTagMapping } from './folder-tag-mapping';
|
||||||
import type { WorkflowEntity } from './workflow-entity';
|
import type { WorkflowEntity } from './workflow-entity';
|
||||||
import type { WorkflowTagMapping } from './workflow-tag-mapping';
|
import type { WorkflowTagMapping } from './workflow-tag-mapping';
|
||||||
|
|
||||||
@@ -18,4 +19,7 @@ export class TagEntity extends WithTimestampsAndStringId {
|
|||||||
|
|
||||||
@OneToMany('WorkflowTagMapping', 'tags')
|
@OneToMany('WorkflowTagMapping', 'tags')
|
||||||
workflowMappings: WorkflowTagMapping[];
|
workflowMappings: WorkflowTagMapping[];
|
||||||
|
|
||||||
|
@OneToMany('FolderTagMapping', 'tags')
|
||||||
|
folderMappings: FolderTagMapping[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,200 @@
|
|||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
|
import type { SelectQueryBuilder } from '@n8n/typeorm';
|
||||||
import { DataSource, Repository } from '@n8n/typeorm';
|
import { DataSource, Repository } from '@n8n/typeorm';
|
||||||
|
|
||||||
|
import type { ListQuery } from '@/requests';
|
||||||
|
|
||||||
import { Folder } from '../entities/folder';
|
import { Folder } from '../entities/folder';
|
||||||
|
import { FolderTagMapping } from '../entities/folder-tag-mapping';
|
||||||
|
import { TagEntity } from '../entities/tag-entity';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class FolderRepository extends Repository<Folder> {
|
export class FolderRepository extends Repository<Folder> {
|
||||||
constructor(dataSource: DataSource) {
|
constructor(dataSource: DataSource) {
|
||||||
super(Folder, dataSource.manager);
|
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<Folder>,
|
||||||
|
select?: Record<string, boolean>,
|
||||||
|
): void {
|
||||||
|
if (select) {
|
||||||
|
this.applyCustomSelect(query, select);
|
||||||
|
} else {
|
||||||
|
this.applyDefaultSelect(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyDefaultSelect(query: SelectQueryBuilder<Folder>): 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<Folder>,
|
||||||
|
select?: Record<string, boolean>,
|
||||||
|
): void {
|
||||||
|
const selections = ['folder.id'];
|
||||||
|
|
||||||
|
this.addBasicFields(selections, select);
|
||||||
|
this.addRelationFields(query, selections, select);
|
||||||
|
|
||||||
|
query.select(selections);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addBasicFields(selections: string[], select?: Record<string, boolean>): 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<Folder>,
|
||||||
|
selections: string[],
|
||||||
|
select?: Record<string, boolean>,
|
||||||
|
): 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<Folder>,
|
||||||
|
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<Folder>,
|
||||||
|
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<Folder>, 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<Folder>,
|
||||||
|
tags: string[],
|
||||||
|
): SelectQueryBuilder<FolderTagMapping> {
|
||||||
|
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<Folder>, 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<Folder>,
|
||||||
|
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<Folder>, options: ListQuery.Options): void {
|
||||||
|
if (options?.take) {
|
||||||
|
query.skip(options.skip ?? 0).take(options.take);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import type { TagEntity } from '@/databases/entities/tag-entity';
|
|||||||
import type { AssignableRole, GlobalRole, User } from '@/databases/entities/user';
|
import type { AssignableRole, GlobalRole, User } from '@/databases/entities/user';
|
||||||
|
|
||||||
import type { LICENSE_FEATURES, LICENSE_QUOTAS } from './constants';
|
import type { LICENSE_FEATURES, LICENSE_QUOTAS } from './constants';
|
||||||
|
import type { Folder } from './databases/entities/folder';
|
||||||
import type { ExternalHooks } from './external-hooks';
|
import type { ExternalHooks } from './external-hooks';
|
||||||
import type { WorkflowWithSharingsAndCredentials } from './workflows/workflows.types';
|
import type { WorkflowWithSharingsAndCredentials } from './workflows/workflows.types';
|
||||||
|
|
||||||
@@ -92,6 +93,7 @@ export type IAnnotationTagWithCountDb = IAnnotationTagDb & UsageCount;
|
|||||||
export interface IWorkflowDb extends IWorkflowBase {
|
export interface IWorkflowDb extends IWorkflowBase {
|
||||||
triggerCount: number;
|
triggerCount: number;
|
||||||
tags?: TagEntity[];
|
tags?: TagEntity[];
|
||||||
|
parentFolder?: Folder | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkflowToImport extends IWorkflowBase {
|
export interface IWorkflowToImport extends IWorkflowBase {
|
||||||
|
|||||||
32
packages/cli/test/integration/shared/db/folders.ts
Normal file
32
packages/cli/test/integration/shared/db/folders.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
@@ -85,6 +85,7 @@ const repositories = [
|
|||||||
'WorkflowStatistics',
|
'WorkflowStatistics',
|
||||||
'WorkflowTagMapping',
|
'WorkflowTagMapping',
|
||||||
'ApiKey',
|
'ApiKey',
|
||||||
|
'Folder',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user