mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
feat(core): Add support for partial-match execution filters (#15797)
This commit is contained in:
@@ -175,7 +175,7 @@ export namespace ExecutionSummaries {
|
|||||||
status: ExecutionStatus[];
|
status: ExecutionStatus[];
|
||||||
workflowId: string;
|
workflowId: string;
|
||||||
waitTill: boolean;
|
waitTill: boolean;
|
||||||
metadata: Array<{ key: string; value: string }>;
|
metadata: Array<{ key: string; value: string; exactMatch?: boolean }>;
|
||||||
startedAfter: string;
|
startedAfter: string;
|
||||||
startedBefore: string;
|
startedBefore: string;
|
||||||
annotationTags: string[]; // tag IDs
|
annotationTags: string[]; // tag IDs
|
||||||
@@ -341,7 +341,7 @@ export interface IGetExecutionsQueryFilter {
|
|||||||
workflowId?: string;
|
workflowId?: string;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
waitTill?: FindOperator<any> | boolean;
|
waitTill?: FindOperator<any> | boolean;
|
||||||
metadata?: Array<{ key: string; value: string }>;
|
metadata?: Array<{ key: string; value: string; exactMatch?: boolean }>;
|
||||||
startedAfter?: string;
|
startedAfter?: string;
|
||||||
startedBefore?: string;
|
startedBefore?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export interface IGetExecutionsQueryFilter {
|
|||||||
workflowId?: string;
|
workflowId?: string;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
waitTill?: FindOperator<any> | boolean;
|
waitTill?: FindOperator<any> | boolean;
|
||||||
metadata?: Array<{ key: string; value: string }>;
|
metadata?: Array<{ key: string; value: string; exactMatch?: boolean }>;
|
||||||
startedAfter?: string;
|
startedAfter?: string;
|
||||||
startedBefore?: string;
|
startedBefore?: string;
|
||||||
}
|
}
|
||||||
@@ -88,7 +88,11 @@ function parseFiltersToQueryBuilder(
|
|||||||
if (filters?.metadata) {
|
if (filters?.metadata) {
|
||||||
qb.leftJoin(ExecutionMetadata, 'md', 'md.executionId = execution.id');
|
qb.leftJoin(ExecutionMetadata, 'md', 'md.executionId = execution.id');
|
||||||
for (const md of filters.metadata) {
|
for (const md of filters.metadata) {
|
||||||
|
if (md.exactMatch) {
|
||||||
qb.andWhere('md.key = :key AND md.value = :value', md);
|
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) {
|
if (filters?.startedAfter) {
|
||||||
@@ -983,16 +987,18 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
|||||||
if (startedAfter) qb.andWhere({ startedAt: moreThanOrEqual(startedAfter) });
|
if (startedAfter) qb.andWhere({ startedAt: moreThanOrEqual(startedAfter) });
|
||||||
|
|
||||||
if (metadata?.length === 1) {
|
if (metadata?.length === 1) {
|
||||||
const [{ key, value }] = metadata;
|
const [{ key, value, exactMatch }] = metadata;
|
||||||
|
|
||||||
qb.innerJoin(
|
const executionIdMatch = 'md.executionId = execution.id';
|
||||||
ExecutionMetadata,
|
const keyMatch = exactMatch ? 'md.key = :key' : 'LOWER(md.key) = LOWER(:key)';
|
||||||
'md',
|
const valueMatch = exactMatch ? 'md.value = :value' : 'LOWER(md.value) LIKE LOWER(:value)';
|
||||||
'md.executionId = execution.id AND md.key = :key AND md.value = :value',
|
|
||||||
);
|
const matches = [executionIdMatch, keyMatch, valueMatch];
|
||||||
|
|
||||||
|
qb.innerJoin(ExecutionMetadata, 'md', matches.join(' AND '));
|
||||||
|
|
||||||
qb.setParameter('key', key);
|
qb.setParameter('key', key);
|
||||||
qb.setParameter('value', value);
|
qb.setParameter('value', exactMatch ? value : `%${value}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ export const schemaGetExecutionsQueryFilter = {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
},
|
},
|
||||||
value: { type: 'string' },
|
value: { type: 'string' },
|
||||||
|
exactMatch: {
|
||||||
|
type: 'boolean',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -244,7 +248,7 @@ export class ExecutionService {
|
|||||||
|
|
||||||
async delete(req: ExecutionRequest.Delete, sharedWorkflowIds: string[]) {
|
async delete(req: ExecutionRequest.Delete, sharedWorkflowIds: string[]) {
|
||||||
const { deleteBefore, ids, filters: requestFiltersRaw } = req.body;
|
const { deleteBefore, ids, filters: requestFiltersRaw } = req.body;
|
||||||
let requestFilters;
|
let requestFilters: IGetExecutionsQueryFilter | undefined;
|
||||||
if (requestFiltersRaw) {
|
if (requestFiltersRaw) {
|
||||||
try {
|
try {
|
||||||
Object.keys(requestFiltersRaw).map((key) => {
|
Object.keys(requestFiltersRaw).map((key) => {
|
||||||
|
|||||||
@@ -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 workflow = await createWorkflow();
|
||||||
|
|
||||||
const metadata = [{ key: 'myKey', value: 'myValue' }];
|
const key = 'myKey';
|
||||||
|
const value = 'myValue';
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
createExecution({ status: 'success', metadata }, workflow),
|
createExecution({ status: 'success', metadata: [{ key, value }] }, workflow),
|
||||||
createExecution({ status: 'error' }, workflow),
|
createExecution({ status: 'error', metadata: [{ key, value: `${value}2` }] }, workflow),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const query: ExecutionSummaries.RangeQuery = {
|
const query: ExecutionSummaries.RangeQuery = {
|
||||||
kind: 'range',
|
kind: 'range',
|
||||||
range: { limit: 20 },
|
range: { limit: 20 },
|
||||||
accessibleWorkflowIds: [workflow.id],
|
accessibleWorkflowIds: [workflow.id],
|
||||||
metadata,
|
metadata: [{ key, value, exactMatch: true }],
|
||||||
};
|
};
|
||||||
|
|
||||||
const output = await executionService.findRangeWithCount(query);
|
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 () => {
|
test('should filter executions by `projectId`', async () => {
|
||||||
const firstProject = await createTeamProject();
|
const firstProject = await createTeamProject();
|
||||||
const secondProject = await createTeamProject();
|
const secondProject = await createTeamProject();
|
||||||
|
|||||||
@@ -877,9 +877,10 @@
|
|||||||
"executionsFilter.startDate": "Earliest",
|
"executionsFilter.startDate": "Earliest",
|
||||||
"executionsFilter.endDate": "Latest",
|
"executionsFilter.endDate": "Latest",
|
||||||
"executionsFilter.savedData": "Highlighted data",
|
"executionsFilter.savedData": "Highlighted data",
|
||||||
|
"executionsFilter.savedDataExactMatch": "Exact match",
|
||||||
"executionsFilter.savedDataKey": "Key",
|
"executionsFilter.savedDataKey": "Key",
|
||||||
"executionsFilter.savedDataKeyPlaceholder": "ID",
|
"executionsFilter.savedDataKeyPlaceholder": "ID",
|
||||||
"executionsFilter.savedDataValue": "Value (exact match)",
|
"executionsFilter.savedDataValue": "Value",
|
||||||
"executionsFilter.savedDataValuePlaceholder": "123",
|
"executionsFilter.savedDataValuePlaceholder": "123",
|
||||||
"executionsFilter.reset": "Reset all",
|
"executionsFilter.reset": "Reset all",
|
||||||
"executionsFilter.customData.inputTooltip": "Upgrade plan to filter executions by custom data set at runtime. {link}",
|
"executionsFilter.customData.inputTooltip": "Upgrade plan to filter executions by custom data set at runtime. {link}",
|
||||||
|
|||||||
@@ -1344,6 +1344,7 @@ export interface EnvironmentVariable {
|
|||||||
export type ExecutionFilterMetadata = {
|
export type ExecutionFilterMetadata = {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
exactMatch?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExecutionFilterVote = AnnotationVote | 'all';
|
export type ExecutionFilterVote = AnnotationVote | 'all';
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const defaultFilterState: ExecutionFilterType = {
|
|||||||
annotationTags: [],
|
annotationTags: [],
|
||||||
startDate: '',
|
startDate: '',
|
||||||
endDate: '',
|
endDate: '',
|
||||||
metadata: [{ key: '', value: '' }],
|
metadata: [{ key: '', value: '', exactMatch: false }],
|
||||||
vote: 'all',
|
vote: 'all',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const getDefaultFilter = (): ExecutionFilterType => ({
|
|||||||
annotationTags: [],
|
annotationTags: [],
|
||||||
startDate: '',
|
startDate: '',
|
||||||
endDate: '',
|
endDate: '',
|
||||||
metadata: [{ key: '', value: '' }],
|
metadata: [{ key: '', value: '', exactMatch: false }],
|
||||||
vote: 'all',
|
vote: 'all',
|
||||||
});
|
});
|
||||||
const filter = reactive(getDefaultFilter());
|
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
|
// 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
|
// 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]) {
|
if (!filter.metadata[index]) {
|
||||||
filter.metadata[index] = {
|
filter.metadata[index] = {
|
||||||
key: '',
|
key: '',
|
||||||
value: '',
|
value: '',
|
||||||
|
exactMatch: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
filter.metadata[index][prop] = value;
|
filter.metadata[index][prop] = value;
|
||||||
@@ -327,6 +332,26 @@ onBeforeMount(() => {
|
|||||||
@update:model-value="onFilterMetaChange(0, 'key', $event)"
|
@update:model-value="onFilterMetaChange(0, 'key', $event)"
|
||||||
/>
|
/>
|
||||||
</n8n-tooltip>
|
</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">{{
|
<label for="execution-filter-saved-data-value">{{
|
||||||
locale.baseText('executionsFilter.savedDataValue')
|
locale.baseText('executionsFilter.savedDataValue')
|
||||||
}}</label>
|
}}</label>
|
||||||
@@ -373,6 +398,7 @@ onBeforeMount(() => {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: var(--font-size-2xs);
|
font-size: var(--font-size-2xs);
|
||||||
margin: var(--spacing-s) 0 var(--spacing-3xs);
|
margin: var(--spacing-s) 0 var(--spacing-3xs);
|
||||||
|
color: var(--color-text-dark);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +410,15 @@ onBeforeMount(() => {
|
|||||||
font-size: var(--font-size-3xs);
|
font-size: var(--font-size-3xs);
|
||||||
margin: var(--spacing-4xs) 0 var(--spacing-4xs);
|
margin: var(--spacing-4xs) 0 var(--spacing-4xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkboxWrapper {
|
||||||
|
margin-top: var(--spacing-s);
|
||||||
|
margin-bottom: var(--spacing-2xs);
|
||||||
|
|
||||||
|
label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dates {
|
.dates {
|
||||||
|
|||||||
Reference in New Issue
Block a user