feat(core): Update endpoint to retrieve folder to allow returning the path to root (no-changelog) (#15158)

This commit is contained in:
Ricardo Espinoza
2025-05-13 10:09:36 -04:00
committed by GitHub
parent 8944d62c06
commit 42016143ab
6 changed files with 145 additions and 39 deletions

View File

@@ -12,6 +12,7 @@ const VALID_SELECT_FIELDS = [
'parentFolder',
'workflowCount',
'subFolderCount',
'path',
] as const;
const VALID_SORT_OPTIONS = [

View File

@@ -272,8 +272,12 @@ export const enum StatisticsNames {
export type AuthProviderType = 'ldap' | 'email' | 'saml'; // | 'google';
export type FolderWithWorkflowAndSubFolderCount = Folder & {
workflowCount: boolean;
subFolderCount: number;
workflowCount?: boolean;
subFolderCount?: number;
};
export type FolderWithWorkflowAndSubFolderCountAndPath = FolderWithWorkflowAndSubFolderCount & {
path?: string[];
};
export type TestRunFinalResult = 'success' | 'error' | 'warning';

View File

@@ -4,29 +4,28 @@ import { DataSource, Repository } from '@n8n/typeorm';
import { PROJECT_ROOT } from 'n8n-workflow';
import { Folder, FolderTagMapping, TagEntity } from '../entities';
import type { FolderWithWorkflowAndSubFolderCount, ListQuery } from '../entities/types-db';
import type { FolderWithWorkflowAndSubFolderCountAndPath, ListQuery } from '../entities/types-db';
@Service()
export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderCount> {
export class FolderRepository extends Repository<Folder> {
constructor(dataSource: DataSource) {
super(Folder, dataSource.manager);
}
async getManyAndCount(
options: ListQuery.Options = {},
): Promise<[FolderWithWorkflowAndSubFolderCount[], number]> {
async getManyAndCount(options: ListQuery.Options = {}) {
const query = this.getManyQuery(options);
return await query.getManyAndCount();
return (await query.getManyAndCount()) as unknown as [
FolderWithWorkflowAndSubFolderCountAndPath[],
number,
];
}
async getMany(options: ListQuery.Options = {}): Promise<FolderWithWorkflowAndSubFolderCount[]> {
async getMany(options: ListQuery.Options = {}) {
const query = this.getManyQuery(options);
return await query.getMany();
return (await query.getMany()) as unknown as FolderWithWorkflowAndSubFolderCountAndPath[];
}
getManyQuery(
options: ListQuery.Options = {},
): SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount> {
getManyQuery(options: ListQuery.Options = {}): SelectQueryBuilder<Folder> {
const query = this.createQueryBuilder('folder');
this.applySelections(query, options.select, options.filter);
@@ -38,7 +37,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
}
private applySelections(
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
query: SelectQueryBuilder<Folder>,
select?: ListQuery.Options['select'],
filter?: ListQuery.Options['filter'],
): void {
@@ -50,7 +49,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
}
private applyWorkflowCountSelect(
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
query: SelectQueryBuilder<Folder>,
filter?: ListQuery.Options['filter'],
): void {
if (typeof filter?.isArchived === 'boolean') {
@@ -65,7 +64,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
}
private applyDefaultSelect(
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
query: SelectQueryBuilder<Folder>,
filter?: ListQuery.Options['filter'],
): void {
this.applyWorkflowCountSelect(query, filter);
@@ -84,7 +83,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
}
private applyCustomSelect(
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
query: SelectQueryBuilder<Folder>,
select?: ListQuery.Options['select'],
filter?: ListQuery.Options['filter'],
): void {
@@ -103,7 +102,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
}
private addRelationFields(
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
query: SelectQueryBuilder<Folder>,
selections: string[],
select?: ListQuery.Options['select'],
filter?: ListQuery.Options['filter'],
@@ -147,7 +146,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
}
private applyFilters(
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
query: SelectQueryBuilder<Folder>,
filter?: ListQuery.Options['filter'],
): void {
if (!filter) return;
@@ -164,7 +163,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
}
private applyBasicFilters(
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
query: SelectQueryBuilder<Folder>,
filter: ListQuery.Options['filter'],
): void {
if (filter?.folderIds && Array.isArray(filter.folderIds)) {
@@ -196,10 +195,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
}
}
private applyTagsFilter(
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
tags?: string[],
): void {
private applyTagsFilter(query: SelectQueryBuilder<Folder>, tags?: string[]): void {
if (!Array.isArray(tags) || tags.length === 0) return;
const subQuery = this.createTagsSubQuery(query, tags);
@@ -211,7 +207,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
}
private createTagsSubQuery(
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
query: SelectQueryBuilder<Folder>,
tags: string[],
): SelectQueryBuilder<FolderTagMapping> {
return query
@@ -226,10 +222,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
});
}
private applySorting(
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
sortBy?: string,
): void {
private applySorting(query: SelectQueryBuilder<Folder>, sortBy?: string): void {
if (!sortBy) {
query.orderBy('folder.updatedAt', 'DESC');
return;
@@ -245,7 +238,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
}
private applySortingByField(
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
query: SelectQueryBuilder<Folder>,
field: string,
direction: 'DESC' | 'ASC',
): void {
@@ -258,10 +251,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
}
}
private applyPagination(
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
options: ListQuery.Options,
): void {
private applyPagination(query: SelectQueryBuilder<Folder>, options: ListQuery.Options): void {
if (options?.take) {
query.skip(options.skip ?? 0).take(options.take);
}
@@ -320,7 +310,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
}
private applyExcludeFolderFilter(
query: SelectQueryBuilder<FolderWithWorkflowAndSubFolderCount>,
query: SelectQueryBuilder<Folder>,
excludeFolderIdAndDescendants: string,
): void {
// Exclude the specific folder by ID
@@ -395,4 +385,55 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
const result = await query.getRawMany<{ id: string }>();
return result.map((row) => row.id);
}
async getFolderPathsToRoot(folderIds: string[]): Promise<Map<string, string[]>> {
if (!folderIds.length) {
return new Map();
}
// Base query: select all root folders
const baseQuery = this.createQueryBuilder('folder')
.select([
'folder.id as id',
'folder.name as name',
'folder.parentFolderId as parentFolderId',
'CONCAT(folder.name) as path',
'1 as level',
])
.where('folder.parentFolderId IS NULL');
const recursiveQuery = this.createQueryBuilder('child')
.select([
'child.id as id',
'child.name as name',
'child.parentFolderId as parentFolderId',
"CONCAT(parent.path, '/', child.name) as path",
'parent.level + 1 as level',
])
.innerJoin('folder_paths', 'parent', 'child.parentFolderId = parent.id');
const mainQuery = this.createQueryBuilder()
.addCommonTableExpression(
`${baseQuery.getQuery()} UNION ALL ${recursiveQuery.getQuery()}`,
'folder_paths',
{ recursive: true },
)
.select('fp.id as folder_id, fp.path as folder_path')
.from('folder_paths', 'fp')
.where('fp.id IN (:...folderIds)', { folderIds });
const results = await mainQuery.getRawMany<{
folder_id: string;
folder_path: string;
}>();
const pathMap = new Map<string, string[]>();
for (const row of results) {
const pathNames = row.folder_path.split('/');
pathMap.set(row.folder_id, pathNames);
}
return pathMap;
}
}

View File

@@ -13,7 +13,6 @@ import type {
import { PROJECT_ROOT } from 'n8n-workflow';
import { FolderRepository } from './folder.repository';
import type { Folder } from '../entities';
import { WebhookEntity, TagEntity, WorkflowEntity, WorkflowTagMapping } from '../entities';
import type {
ListQueryDb,
@@ -348,7 +347,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
baseData: WorkflowFolderUnionRow[],
extraData: {
workflows: ListQueryDb.Workflow.WithSharing[] | ListQueryDb.Workflow.Plain[];
folders: Folder[];
folders: FolderWithWorkflowAndSubFolderCount[];
},
): WorkflowFolderUnionFull[] {
const workflowsMap = new Map(extraData.workflows.map((workflow) => [workflow.id, workflow]));

View File

@@ -1,5 +1,9 @@
import type { CreateFolderDto, DeleteFolderDto, UpdateFolderDto } from '@n8n/api-types';
import type { User } from '@n8n/db';
import type {
FolderWithWorkflowAndSubFolderCount,
FolderWithWorkflowAndSubFolderCountAndPath,
User,
} from '@n8n/db';
import { Folder, FolderTagMappingRepository, FolderRepository, WorkflowRepository } from '@n8n/db';
import { Service } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
@@ -281,6 +285,27 @@ export class FolderService {
async getManyAndCount(projectId: string, options: ListQuery.Options) {
options.filter = { ...options.filter, projectId, isArchived: false };
return await this.folderRepository.getManyAndCount(options);
// eslint-disable-next-line prefer-const
let [folders, count] = await this.folderRepository.getManyAndCount(options);
if (options.select?.path) {
folders = await this.enrichFoldersWithPaths(folders);
}
return [folders, count];
}
private async enrichFoldersWithPaths(
folders: FolderWithWorkflowAndSubFolderCount[],
): Promise<FolderWithWorkflowAndSubFolderCountAndPath[]> {
const folderIds = folders.map((folder) => folder.id);
const folderPaths = await this.folderRepository.getFolderPathsToRoot(folderIds);
return folders.map(
(folder) =>
({
...folder,
path: folderPaths.get(folder.id),
}) as FolderWithWorkflowAndSubFolderCountAndPath,
);
}
}

View File

@@ -1275,6 +1275,42 @@ describe('GET /projects/:projectId/folders', () => {
expect(response.body.data[0].parentFolder).toBeUndefined();
});
test('should select path field when requested', async () => {
const folder1 = await createFolder(ownerProject, { name: 'Test Folder' });
const folder2 = await createFolder(ownerProject, {
name: 'Test Folder 2',
parentFolder: folder1,
});
const folder3 = await createFolder(ownerProject, {
name: 'Test Folder 3',
parentFolder: folder2,
});
const response = await authOwnerAgent
.get(
`/projects/${ownerProject.id}/folders?select=["id","path", "name"]&sortBy=updatedAt:desc`,
)
.expect(200);
expect(response.body.data[0]).toEqual({
id: expect.any(String),
name: 'Test Folder 3',
path: [folder1.name, folder2.name, folder3.name],
});
expect(response.body.data[1]).toEqual({
id: expect.any(String),
name: 'Test Folder 2',
path: [folder1.name, folder2.name],
});
expect(response.body.data[2]).toEqual({
id: expect.any(String),
name: 'Test Folder',
path: [folder1.name],
});
});
test('should combine multiple query parameters correctly', async () => {
const tag = await createTag({ name: 'important' });
const parentFolder = await createFolder(ownerProject, { name: 'Parent' });