diff --git a/packages/@n8n/db/src/entities/types-db.ts b/packages/@n8n/db/src/entities/types-db.ts index b8533e9668..83a8b4400f 100644 --- a/packages/@n8n/db/src/entities/types-db.ts +++ b/packages/@n8n/db/src/entities/types-db.ts @@ -175,7 +175,7 @@ export namespace ExecutionSummaries { status: ExecutionStatus[]; workflowId: string; waitTill: boolean; - metadata: Array<{ key: string; value: string }>; + metadata: Array<{ key: string; value: string; exactMatch?: boolean }>; startedAfter: string; startedBefore: string; annotationTags: string[]; // tag IDs @@ -341,7 +341,7 @@ export interface IGetExecutionsQueryFilter { workflowId?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any waitTill?: FindOperator | boolean; - metadata?: Array<{ key: string; value: string }>; + metadata?: Array<{ key: string; value: string; exactMatch?: boolean }>; startedAfter?: string; startedBefore?: string; } diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index 2d563cf2d3..9d6c779ddb 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -68,7 +68,7 @@ export interface IGetExecutionsQueryFilter { workflowId?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any waitTill?: FindOperator | boolean; - metadata?: Array<{ key: string; value: string }>; + metadata?: Array<{ key: string; value: string; exactMatch?: boolean }>; startedAfter?: string; startedBefore?: string; } @@ -88,7 +88,11 @@ function parseFiltersToQueryBuilder( if (filters?.metadata) { qb.leftJoin(ExecutionMetadata, 'md', 'md.executionId = execution.id'); for (const md of filters.metadata) { - qb.andWhere('md.key = :key AND md.value = :value', md); + if (md.exactMatch) { + qb.andWhere('md.key = :key AND md.value = :value', md); + } else { + qb.andWhere('md.key = :key AND LOWER(md.value) LIKE LOWER(:value)', md); + } } } if (filters?.startedAfter) { @@ -983,16 +987,18 @@ export class ExecutionRepository extends Repository { if (startedAfter) qb.andWhere({ startedAt: moreThanOrEqual(startedAfter) }); if (metadata?.length === 1) { - const [{ key, value }] = metadata; + const [{ key, value, exactMatch }] = metadata; - qb.innerJoin( - ExecutionMetadata, - 'md', - 'md.executionId = execution.id AND md.key = :key AND md.value = :value', - ); + const executionIdMatch = 'md.executionId = execution.id'; + const keyMatch = exactMatch ? 'md.key = :key' : 'LOWER(md.key) = LOWER(:key)'; + const valueMatch = exactMatch ? 'md.value = :value' : 'LOWER(md.value) LIKE LOWER(:value)'; + + const matches = [executionIdMatch, keyMatch, valueMatch]; + + qb.innerJoin(ExecutionMetadata, 'md', matches.join(' AND ')); qb.setParameter('key', key); - qb.setParameter('value', value); + qb.setParameter('value', exactMatch ? value : `%${value}%`); } // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index 43cc1f62a4..889882aa57 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -80,6 +80,10 @@ export const schemaGetExecutionsQueryFilter = { type: 'string', }, value: { type: 'string' }, + exactMatch: { + type: 'boolean', + default: true, + }, }, }, }, @@ -244,7 +248,7 @@ export class ExecutionService { async delete(req: ExecutionRequest.Delete, sharedWorkflowIds: string[]) { const { deleteBefore, ids, filters: requestFiltersRaw } = req.body; - let requestFilters; + let requestFilters: IGetExecutionsQueryFilter | undefined; if (requestFiltersRaw) { try { Object.keys(requestFiltersRaw).map((key) => { diff --git a/packages/cli/test/integration/execution.service.integration.test.ts b/packages/cli/test/integration/execution.service.integration.test.ts index 609fb73fc8..074761f253 100644 --- a/packages/cli/test/integration/execution.service.integration.test.ts +++ b/packages/cli/test/integration/execution.service.integration.test.ts @@ -270,21 +270,22 @@ describe('ExecutionService', () => { ]); }); - test('should filter executions by `metadata`', async () => { + test('should filter executions by `metadata` with an exact match by default', async () => { const workflow = await createWorkflow(); - const metadata = [{ key: 'myKey', value: 'myValue' }]; + const key = 'myKey'; + const value = 'myValue'; await Promise.all([ - createExecution({ status: 'success', metadata }, workflow), - createExecution({ status: 'error' }, workflow), + createExecution({ status: 'success', metadata: [{ key, value }] }, workflow), + createExecution({ status: 'error', metadata: [{ key, value: `${value}2` }] }, workflow), ]); const query: ExecutionSummaries.RangeQuery = { kind: 'range', range: { limit: 20 }, accessibleWorkflowIds: [workflow.id], - metadata, + metadata: [{ key, value, exactMatch: true }], }; const output = await executionService.findRangeWithCount(query); @@ -296,6 +297,36 @@ describe('ExecutionService', () => { }); }); + test('should filter executions by `metadata` with a partial match', async () => { + const workflow = await createWorkflow(); + + const key = 'myKey'; + + await Promise.all([ + createExecution({ status: 'success', metadata: [{ key, value: 'myValue' }] }, workflow), + createExecution({ status: 'error', metadata: [{ key, value: 'var' }] }, workflow), + createExecution({ status: 'success', metadata: [{ key, value: 'evaluation' }] }, workflow), + ]); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + range: { limit: 20 }, + accessibleWorkflowIds: [workflow.id], + metadata: [{ key, value: 'val', exactMatch: false }], + }; + + const output = await executionService.findRangeWithCount(query); + + expect(output).toEqual({ + count: 2, + estimated: false, + results: [ + expect.objectContaining({ status: 'success' }), + expect.objectContaining({ status: 'success' }), + ], + }); + }); + test('should filter executions by `projectId`', async () => { const firstProject = await createTeamProject(); const secondProject = await createTeamProject(); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index be7a012b63..417193c0cd 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -877,9 +877,10 @@ "executionsFilter.startDate": "Earliest", "executionsFilter.endDate": "Latest", "executionsFilter.savedData": "Highlighted data", + "executionsFilter.savedDataExactMatch": "Exact match", "executionsFilter.savedDataKey": "Key", "executionsFilter.savedDataKeyPlaceholder": "ID", - "executionsFilter.savedDataValue": "Value (exact match)", + "executionsFilter.savedDataValue": "Value", "executionsFilter.savedDataValuePlaceholder": "123", "executionsFilter.reset": "Reset all", "executionsFilter.customData.inputTooltip": "Upgrade plan to filter executions by custom data set at runtime. {link}", diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 04732539e8..e816af6a4e 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -1344,6 +1344,7 @@ export interface EnvironmentVariable { export type ExecutionFilterMetadata = { key: string; value: string; + exactMatch?: boolean; }; export type ExecutionFilterVote = AnnotationVote | 'all'; diff --git a/packages/frontend/editor-ui/src/components/executions/ExecutionsFilter.test.ts b/packages/frontend/editor-ui/src/components/executions/ExecutionsFilter.test.ts index b2fc30df9d..292ec7c46e 100644 --- a/packages/frontend/editor-ui/src/components/executions/ExecutionsFilter.test.ts +++ b/packages/frontend/editor-ui/src/components/executions/ExecutionsFilter.test.ts @@ -17,7 +17,7 @@ const defaultFilterState: ExecutionFilterType = { annotationTags: [], startDate: '', endDate: '', - metadata: [{ key: '', value: '' }], + metadata: [{ key: '', value: '', exactMatch: false }], vote: 'all', }; diff --git a/packages/frontend/editor-ui/src/components/executions/ExecutionsFilter.vue b/packages/frontend/editor-ui/src/components/executions/ExecutionsFilter.vue index bf3aeb05ed..12434fd2ae 100644 --- a/packages/frontend/editor-ui/src/components/executions/ExecutionsFilter.vue +++ b/packages/frontend/editor-ui/src/components/executions/ExecutionsFilter.vue @@ -56,7 +56,7 @@ const getDefaultFilter = (): ExecutionFilterType => ({ annotationTags: [], startDate: '', endDate: '', - metadata: [{ key: '', value: '' }], + metadata: [{ key: '', value: '', exactMatch: false }], vote: 'all', }); const filter = reactive(getDefaultFilter()); @@ -116,11 +116,16 @@ const countSelectedFilterProps = computed(() => { // vModel.metadata is a text input and needs a debounced emit to avoid too many requests // We use the :value and @input combo instead of v-model with this event listener -const onFilterMetaChange = (index: number, prop: keyof ExecutionFilterMetadata, value: string) => { +const onFilterMetaChange = ( + index: number, + prop: K, + value: ExecutionFilterMetadata[K], +) => { if (!filter.metadata[index]) { filter.metadata[index] = { key: '', value: '', + exactMatch: false, }; } filter.metadata[index][prop] = value; @@ -327,6 +332,26 @@ onBeforeMount(() => { @update:model-value="onFilterMetaChange(0, 'key', $event)" /> +
+ + + + +
@@ -373,6 +398,7 @@ onBeforeMount(() => { display: inline-block; font-size: var(--font-size-2xs); margin: var(--spacing-s) 0 var(--spacing-3xs); + color: var(--color-text-dark); } } @@ -384,6 +410,15 @@ onBeforeMount(() => { font-size: var(--font-size-3xs); margin: var(--spacing-4xs) 0 var(--spacing-4xs); } + + .checkboxWrapper { + margin-top: var(--spacing-s); + margin-bottom: var(--spacing-2xs); + + label { + margin: 0; + } + } } .dates {