Skip to content

Commit 35488e7

Browse files
feat(core): Improve workflows text search (#21738)
Co-authored-by: Ricardo Espinoza <[email protected]>
1 parent 343413d commit 35488e7

File tree

2 files changed

+320
-7
lines changed

2 files changed

+320
-7
lines changed
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import { GlobalConfig } from '@n8n/config';
2+
import type { SelectQueryBuilder } from '@n8n/typeorm';
3+
import { mock } from 'jest-mock-extended';
4+
5+
import { WorkflowEntity } from '../../entities';
6+
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
7+
import { mockInstance } from '../../utils/test-utils/mock-instance';
8+
import { FolderRepository } from '../folder.repository';
9+
import { WorkflowRepository } from '../workflow.repository';
10+
11+
describe('WorkflowRepository', () => {
12+
const entityManager = mockEntityManager(WorkflowEntity);
13+
const globalConfig = mockInstance(GlobalConfig, {
14+
database: { type: 'postgresdb' },
15+
});
16+
const folderRepository = mockInstance(FolderRepository);
17+
const workflowRepository = new WorkflowRepository(
18+
entityManager.connection,
19+
globalConfig,
20+
folderRepository,
21+
);
22+
23+
let queryBuilder: jest.Mocked<SelectQueryBuilder<WorkflowEntity>>;
24+
25+
beforeEach(() => {
26+
jest.resetAllMocks();
27+
28+
queryBuilder = mock<SelectQueryBuilder<WorkflowEntity>>();
29+
queryBuilder.where.mockReturnThis();
30+
queryBuilder.andWhere.mockReturnThis();
31+
queryBuilder.orWhere.mockReturnThis();
32+
queryBuilder.select.mockReturnThis();
33+
queryBuilder.addSelect.mockReturnThis();
34+
queryBuilder.leftJoin.mockReturnThis();
35+
queryBuilder.innerJoin.mockReturnThis();
36+
queryBuilder.orderBy.mockReturnThis();
37+
queryBuilder.addOrderBy.mockReturnThis();
38+
queryBuilder.skip.mockReturnThis();
39+
queryBuilder.take.mockReturnThis();
40+
queryBuilder.getMany.mockResolvedValue([]);
41+
queryBuilder.getManyAndCount.mockResolvedValue([[], 0]);
42+
43+
Object.defineProperty(queryBuilder, 'expressionMap', {
44+
value: {
45+
aliases: [],
46+
},
47+
writable: true,
48+
});
49+
50+
jest.spyOn(workflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
51+
});
52+
53+
describe('applyNameFilter', () => {
54+
it('should search for workflows containing any word from the query', async () => {
55+
const workflowIds = ['workflow1'];
56+
const options = {
57+
filter: { query: 'Users database' },
58+
};
59+
60+
await workflowRepository.getMany(workflowIds, options);
61+
62+
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
63+
expect.stringContaining('OR'),
64+
expect.objectContaining({
65+
searchWord0: '%users%',
66+
searchWord1: '%database%',
67+
}),
68+
);
69+
});
70+
71+
it('should handle single word searches', async () => {
72+
const workflowIds = ['workflow1'];
73+
const options = {
74+
filter: { query: 'workflow' },
75+
};
76+
77+
await workflowRepository.getMany(workflowIds, options);
78+
79+
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
80+
expect.any(String),
81+
expect.objectContaining({
82+
searchWord0: '%workflow%',
83+
}),
84+
);
85+
});
86+
87+
it('should handle queries with extra whitespace', async () => {
88+
const workflowIds = ['workflow1'];
89+
const options = {
90+
filter: { query: ' Users database ' },
91+
};
92+
93+
await workflowRepository.getMany(workflowIds, options);
94+
95+
// Should still result in just two words
96+
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
97+
expect.any(String),
98+
expect.objectContaining({
99+
searchWord0: '%users%',
100+
searchWord1: '%database%',
101+
}),
102+
);
103+
});
104+
105+
it('should not apply filter when query is empty', async () => {
106+
const workflowIds = ['workflow1'];
107+
const options = {
108+
filter: { query: '' },
109+
};
110+
111+
await workflowRepository.getMany(workflowIds, options);
112+
113+
// andWhere should not be called for name filter
114+
const nameFilterCalls = (queryBuilder.andWhere as jest.Mock).mock.calls.filter((call) =>
115+
call[0]?.includes('workflow.name'),
116+
);
117+
expect(nameFilterCalls).toHaveLength(0);
118+
});
119+
120+
it('should not apply filter when query is only whitespace', async () => {
121+
const workflowIds = ['workflow1'];
122+
const options = {
123+
filter: { query: ' ' },
124+
};
125+
126+
await workflowRepository.getMany(workflowIds, options);
127+
128+
// andWhere should not be called for name filter
129+
const nameFilterCalls = (queryBuilder.andWhere as jest.Mock).mock.calls.filter((call) =>
130+
call[0]?.includes('workflow.name'),
131+
);
132+
expect(nameFilterCalls).toHaveLength(0);
133+
});
134+
135+
it('should use SQLite concatenation syntax for SQLite database', async () => {
136+
// Create a new repository instance with SQLite config
137+
const sqliteConfig = mockInstance(GlobalConfig, {
138+
database: { type: 'sqlite' },
139+
});
140+
const sqliteWorkflowRepository = new WorkflowRepository(
141+
entityManager.connection,
142+
sqliteConfig,
143+
folderRepository,
144+
);
145+
jest.spyOn(sqliteWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
146+
147+
const workflowIds = ['workflow1'];
148+
const options = {
149+
filter: { query: 'test search' },
150+
};
151+
152+
await sqliteWorkflowRepository.getMany(workflowIds, options);
153+
154+
// Check for SQLite-specific concatenation syntax (||)
155+
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
156+
expect.stringContaining("workflow.name || ' ' || COALESCE"),
157+
expect.any(Object),
158+
);
159+
});
160+
161+
it('should use CONCAT syntax for non-SQLite databases', async () => {
162+
const workflowIds = ['workflow1'];
163+
const options = {
164+
filter: { query: 'test search' },
165+
};
166+
167+
await workflowRepository.getMany(workflowIds, options);
168+
169+
// Check for CONCAT syntax
170+
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
171+
expect.stringContaining('CONCAT(workflow.name'),
172+
expect.any(Object),
173+
);
174+
});
175+
176+
it('should search in both name and description fields', async () => {
177+
const workflowIds = ['workflow1'];
178+
const options = {
179+
filter: { query: 'automation' },
180+
};
181+
182+
await workflowRepository.getMany(workflowIds, options);
183+
184+
const andWhereCall = (queryBuilder.andWhere as jest.Mock).mock.calls.find((call) =>
185+
call[0]?.includes('workflow.name'),
186+
);
187+
188+
expect(andWhereCall).toBeDefined();
189+
expect(andWhereCall[0]).toContain('workflow.name');
190+
expect(andWhereCall[0]).toContain('workflow.description');
191+
});
192+
193+
it('should handle special characters in search query', async () => {
194+
const workflowIds = ['workflow1'];
195+
const options = {
196+
filter: { query: 'test% _query' },
197+
};
198+
199+
await workflowRepository.getMany(workflowIds, options);
200+
201+
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
202+
expect.any(String),
203+
expect.objectContaining({
204+
searchWord0: '%test%%',
205+
searchWord1: '%_query%',
206+
}),
207+
);
208+
});
209+
210+
it('should be case-insensitive', async () => {
211+
const workflowIds = ['workflow1'];
212+
const options = {
213+
filter: { query: 'USERS Database' },
214+
};
215+
216+
await workflowRepository.getMany(workflowIds, options);
217+
218+
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
219+
expect.stringContaining('LOWER'),
220+
expect.objectContaining({
221+
searchWord0: '%users%',
222+
searchWord1: '%database%',
223+
}),
224+
);
225+
});
226+
});
227+
228+
describe('getMany', () => {
229+
it('should apply multiple filters together', async () => {
230+
const workflowIds = ['workflow1', 'workflow2'];
231+
const options = {
232+
filter: {
233+
query: 'automation task',
234+
active: true,
235+
projectId: 'project1',
236+
},
237+
take: 10,
238+
skip: 0,
239+
};
240+
241+
await workflowRepository.getMany(workflowIds, options);
242+
243+
// Check that filters were applied
244+
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
245+
expect.stringContaining('workflow.name'),
246+
expect.objectContaining({
247+
searchWord0: '%automation%',
248+
searchWord1: '%task%',
249+
}),
250+
);
251+
252+
expect(queryBuilder.andWhere).toHaveBeenCalledWith('workflow.active = :active', {
253+
active: true,
254+
});
255+
256+
expect(queryBuilder.innerJoin).toHaveBeenCalledWith('workflow.shared', 'shared');
257+
expect(queryBuilder.andWhere).toHaveBeenCalledWith('shared.projectId = :projectId', {
258+
projectId: 'project1',
259+
});
260+
261+
// Check pagination
262+
expect(queryBuilder.skip).toHaveBeenCalledWith(0);
263+
expect(queryBuilder.take).toHaveBeenCalledWith(10);
264+
});
265+
});
266+
});

packages/@n8n/db/src/repositories/workflow.repository.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -497,18 +497,65 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
497497
}
498498
}
499499

500+
/**
501+
* Parses and normalizes the search query into individual words
502+
*/
503+
private parseSearchWords(searchValue: unknown): string[] {
504+
if (typeof searchValue !== 'string' || searchValue === '') {
505+
return [];
506+
}
507+
508+
return searchValue
509+
.toLowerCase()
510+
.split(/\s+/)
511+
.filter((word) => word.length > 0);
512+
}
513+
514+
/**
515+
* Returns the database-specific SQL expression to concatenate workflow name and description
516+
*/
517+
private getFieldConcatExpression(): string {
518+
const dbType = this.globalConfig.database.type;
519+
520+
return dbType === 'sqlite'
521+
? "LOWER(workflow.name || ' ' || COALESCE(workflow.description, ''))"
522+
: "LOWER(CONCAT(workflow.name, ' ', COALESCE(workflow.description, '')))";
523+
}
524+
525+
/**
526+
* Builds search conditions and parameters for matching any of the search words
527+
*/
528+
private buildSearchConditions(searchWords: string[]): {
529+
conditions: string[];
530+
parameters: Record<string, string>;
531+
} {
532+
const concatExpression = this.getFieldConcatExpression();
533+
534+
const conditions = searchWords.map((_, index) => {
535+
return `${concatExpression} LIKE :searchWord${index}`;
536+
});
537+
538+
const parameters: Record<string, string> = {};
539+
searchWords.forEach((word, index) => {
540+
parameters[`searchWord${index}`] = `%${word}%`;
541+
});
542+
543+
return { conditions, parameters };
544+
}
545+
546+
/**
547+
* Applies a name or description filter to the query builder.
548+
* We are supporting searching by multiple words, where any of the words can match
549+
*/
500550
private applyNameFilter(
501551
qb: SelectQueryBuilder<WorkflowEntity>,
502552
filter: ListQuery.Options['filter'],
503553
): void {
504-
const searchValue = filter?.query;
554+
const searchWords = this.parseSearchWords(filter?.query);
505555

506-
if (typeof searchValue === 'string' && searchValue !== '') {
507-
const searchTerm = `%${searchValue.toLowerCase()}%`;
508-
qb.andWhere(
509-
"(LOWER(workflow.name) LIKE :searchTerm OR LOWER(COALESCE(workflow.description, '')) LIKE :searchTerm)",
510-
{ searchTerm },
511-
);
556+
if (searchWords.length > 0) {
557+
const { conditions, parameters } = this.buildSearchConditions(searchWords);
558+
qb.andWhere(`(${conditions.join(' OR ')})`, parameters);
512559
}
513560
}
514561

0 commit comments

Comments
 (0)