feat(core): Add support for partial-match execution filters (#15797)

This commit is contained in:
Daria
2025-06-04 09:37:35 +03:00
committed by GitHub
parent 7639cfbaa7
commit 1335af05d5
8 changed files with 99 additions and 21 deletions

View File

@@ -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<any> | boolean;
metadata?: Array<{ key: string; value: string }>;
metadata?: Array<{ key: string; value: string; exactMatch?: boolean }>;
startedAfter?: string;
startedBefore?: string;
}

View File

@@ -68,7 +68,7 @@ export interface IGetExecutionsQueryFilter {
workflowId?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
waitTill?: FindOperator<any> | 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<ExecutionEntity> {
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

View File

@@ -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) => {

View File

@@ -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();

View File

@@ -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}",

View File

@@ -1344,6 +1344,7 @@ export interface EnvironmentVariable {
export type ExecutionFilterMetadata = {
key: string;
value: string;
exactMatch?: boolean;
};
export type ExecutionFilterVote = AnnotationVote | 'all';

View File

@@ -17,7 +17,7 @@ const defaultFilterState: ExecutionFilterType = {
annotationTags: [],
startDate: '',
endDate: '',
metadata: [{ key: '', value: '' }],
metadata: [{ key: '', value: '', exactMatch: false }],
vote: 'all',
};

View File

@@ -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 = <K extends keyof ExecutionFilterMetadata>(
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)"
/>
</n8n-tooltip>
<div :class="$style.checkboxWrapper">
<n8n-tooltip :disabled="isAdvancedExecutionFilterEnabled" placement="top">
<template #content>
<i18n-t tag="span" keypath="executionsFilter.customData.inputTooltip">
<template #link>
<a href="#" @click.prevent="goToUpgrade">{{
locale.baseText('executionsFilter.customData.inputTooltip.link')
}}</a>
</template>
</i18n-t>
</template>
<n8n-checkbox
:label="locale.baseText('executionsFilter.savedDataExactMatch')"
:model-value="filter.metadata[0]?.exactMatch"
:disabled="!isAdvancedExecutionFilterEnabled"
data-test-id="execution-filter-saved-data-exact-match-checkbox"
@update:model-value="onFilterMetaChange(0, 'exactMatch', $event)"
/>
</n8n-tooltip>
</div>
<label for="execution-filter-saved-data-value">{{
locale.baseText('executionsFilter.savedDataValue')
}}</label>
@@ -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 {