feat(Data Table Node): Add new filters to node operation conditions (no-changelog) (#18942)

Co-authored-by: Charlie Kolb <charlie@n8n.io>
This commit is contained in:
Daria
2025-08-29 10:14:46 +03:00
committed by GitHub
parent 6379ce53a9
commit 1bc317dce5
6 changed files with 309 additions and 16 deletions

View File

@@ -3,7 +3,12 @@ import { NodeConnectionTypes } from 'n8n-workflow';
import { router } from './actions/router';
import * as row from './actions/row/Row.resource';
import { getDataTableColumns, getDataTables, tableSearch } from './common/methods';
import {
getConditionsForColumn,
getDataTableColumns,
getDataTables,
tableSearch,
} from './common/methods';
export class DataTable implements INodeType {
description: INodeTypeDescription = {
@@ -48,6 +53,7 @@ export class DataTable implements INodeType {
},
loadOptions: {
getDataTableColumns,
getConditionsForColumn,
},
resourceMapping: {
getDataTables,

View File

@@ -7,6 +7,6 @@ export type FilterType = typeof ANY_FILTER | typeof ALL_FILTERS;
export type FieldEntry = {
keyName: string;
condition: 'eq' | 'neq';
condition: 'eq' | 'neq' | 'like' | 'ilike' | 'gt' | 'gte' | 'lt' | 'lte';
keyValue: DataStoreColumnJsType;
};

View File

@@ -42,19 +42,95 @@ export async function tableSearch(
}
export async function getDataTableColumns(this: ILoadOptionsFunctions) {
const returnData: Array<INodePropertyOptions & { type: string }> = [
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased-id, n8n-nodes-base/node-param-display-name-miscased
const returnData: INodePropertyOptions[] = [{ name: 'id - (number)', value: 'id' }];
{ name: 'id - (number)', value: 'id', type: 'number' },
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
{ name: 'createdAt - (date)', value: 'createdAt', type: 'date' },
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
{ name: 'updatedAt - (date)', value: 'updatedAt', type: 'date' },
];
const proxy = await getDataTableProxyLoadOptions(this);
const columns = await proxy.getColumns();
for (const column of columns) {
returnData.push({
name: `${column.name} - (${column.type})`,
value: column.name,
type: column.type,
});
}
return returnData;
}
const systemColumns = [
{ name: 'id', type: 'number' },
{ name: 'createdAt', type: 'date' },
{ name: 'updatedAt', type: 'date' },
] as const;
export async function getConditionsForColumn(this: ILoadOptionsFunctions) {
const keyName = this.getCurrentNodeParameter('&keyName') as string;
// Base conditions available for all column types
const baseConditions: INodePropertyOptions[] = [
{ name: 'Equals', value: 'eq' },
{ name: 'Not Equals', value: 'neq' },
];
const comparableConditions: INodePropertyOptions[] = [
{ name: 'Greater Than', value: 'gt' },
{ name: 'Greater Than or Equal', value: 'gte' },
{ name: 'Less Than', value: 'lt' },
{ name: 'Less Than or Equal', value: 'lte' },
];
const stringConditions: INodePropertyOptions[] = [
{
name: 'LIKE operator',
value: 'like',
description:
'Case-sensitive pattern matching. Use % as wildcard (e.g., "%Mar%" to match "Anne-Marie").',
},
{
name: 'ILIKE operator',
value: 'ilike',
description:
'Case-insensitive pattern matching. Use % as wildcard (e.g., "%mar%" to match "Anne-Marie").',
},
];
const allConditions = [...baseConditions, ...comparableConditions, ...stringConditions];
// If no column is selected yet, return all conditions
if (!keyName) {
return allConditions;
}
// Get column type to determine available conditions
const column =
systemColumns.find((col) => col.name === keyName) ??
(await (await getDataTableProxyLoadOptions(this)).getColumns()).find(
(col) => col.name === keyName,
);
if (!column) {
return baseConditions;
}
const conditions = baseConditions;
// String columns get LIKE operators
if (column.type === 'string') {
conditions.push.apply(conditions, stringConditions);
}
if (['number', 'date', 'string'].includes(column.type)) {
conditions.push.apply(conditions, comparableConditions);
}
return conditions;
}
export async function getDataTables(this: ILoadOptionsFunctions): Promise<ResourceMapperFields> {
const proxy = await getDataTableProxyLoadOptions(this);
const result = await proxy.getColumns();

View File

@@ -62,19 +62,15 @@ export function getSelectFields(
default: 'id',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Condition',
name: 'condition',
// eslint-disable-next-line n8n-nodes-base/node-param-description-missing-from-dynamic-options
type: 'options',
options: [
{
name: 'Equals',
value: 'eq',
typeOptions: {
loadOptionsDependsOn: ['&keyName'],
loadOptionsMethod: 'getConditionsForColumn',
},
{
name: 'Not Equals',
value: 'neq',
},
],
default: 'eq',
},
{

View File

@@ -6,7 +6,7 @@ import {
} from 'n8n-workflow';
import type { FieldEntry } from '../../common/constants';
import { ANY_FILTER } from '../../common/constants';
import { ANY_FILTER, ALL_FILTERS } from '../../common/constants';
import { DATA_TABLE_ID_FIELD } from '../../common/fields';
import { executeSelectMany } from '../../common/selectMany';
@@ -56,6 +56,7 @@ describe('selectMany utils', () => {
// ASSERT
expect(result).toEqual([{ json: { id: 1 } }]);
});
it('should get a paginated amount of rows', async () => {
// ARRANGE
getManyRowsAndCount.mockReturnValueOnce({
@@ -82,6 +83,7 @@ describe('selectMany utils', () => {
expect(result[0]).toEqual({ json: { id: 0 } });
expect(result[2344]).toEqual({ json: { id: 2344 } });
});
it('should pass null through correctly', async () => {
// ARRANGE
getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, colA: null }], count: 1 });
@@ -92,6 +94,7 @@ describe('selectMany utils', () => {
// ASSERT
expect(result).toEqual([{ json: { id: 1, colA: null } }]);
});
it('should panic if pagination gets out of sync', async () => {
// ARRANGE
getManyRowsAndCount.mockReturnValueOnce({
@@ -113,5 +116,217 @@ describe('selectMany utils', () => {
),
);
});
describe('filter conditions', () => {
it('should handle "eq" condition', async () => {
// ARRANGE
filters = [{ condition: 'eq', keyName: 'name', keyValue: 'John' }];
getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, name: 'John' }], count: 1 });
// ACT
const result = await executeSelectMany(mockExecuteFunctions, 0, dataStoreProxy);
// ASSERT
expect(result).toEqual([{ json: { id: 1, name: 'John' } }]);
});
it('should handle "neq" condition', async () => {
// ARRANGE
filters = [{ condition: 'neq', keyName: 'name', keyValue: 'John' }];
getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, name: 'Jane' }], count: 1 });
// ACT
const result = await executeSelectMany(mockExecuteFunctions, 0, dataStoreProxy);
// ASSERT
expect(result).toEqual([{ json: { id: 1, name: 'Jane' } }]);
});
it('should handle "gt" condition with numbers', async () => {
// ARRANGE
filters = [{ condition: 'gt', keyName: 'age', keyValue: 25 }];
getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, age: 30 }], count: 1 });
// ACT
const result = await executeSelectMany(mockExecuteFunctions, 0, dataStoreProxy);
// ASSERT
expect(result).toEqual([{ json: { id: 1, age: 30 } }]);
});
it('should handle "gte" condition with numbers', async () => {
// ARRANGE
filters = [{ condition: 'gte', keyName: 'age', keyValue: 25 }];
getManyRowsAndCount.mockReturnValue({
data: [
{ id: 1, age: 25 },
{ id: 2, age: 30 },
],
count: 2,
});
// ACT
const result = await executeSelectMany(mockExecuteFunctions, 0, dataStoreProxy);
// ASSERT
expect(result).toEqual([{ json: { id: 1, age: 25 } }, { json: { id: 2, age: 30 } }]);
});
it('should handle "lt" condition with numbers', async () => {
// ARRANGE
filters = [{ condition: 'lt', keyName: 'age', keyValue: 30 }];
getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, age: 25 }], count: 1 });
// ACT
const result = await executeSelectMany(mockExecuteFunctions, 0, dataStoreProxy);
// ASSERT
expect(result).toEqual([{ json: { id: 1, age: 25 } }]);
});
it('should handle "lte" condition with numbers', async () => {
// ARRANGE
filters = [{ condition: 'lte', keyName: 'age', keyValue: 30 }];
getManyRowsAndCount.mockReturnValue({
data: [
{ id: 1, age: 25 },
{ id: 2, age: 30 },
],
count: 2,
});
// ACT
const result = await executeSelectMany(mockExecuteFunctions, 0, dataStoreProxy);
// ASSERT
expect(result).toEqual([{ json: { id: 1, age: 25 } }, { json: { id: 2, age: 30 } }]);
});
it('should handle "like" condition with pattern matching', async () => {
// ARRANGE
filters = [{ condition: 'like', keyName: 'name', keyValue: '%Mar%' }];
getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, name: 'Anne-Marie' }], count: 1 });
// ACT
const result = await executeSelectMany(mockExecuteFunctions, 0, dataStoreProxy);
// ASSERT
expect(result).toEqual([{ json: { id: 1, name: 'Anne-Marie' } }]);
});
it('should handle "ilike" condition with case-insensitive pattern matching', async () => {
// ARRANGE
filters = [{ condition: 'ilike', keyName: 'name', keyValue: '%mar%' }];
getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, name: 'Anne-Marie' }], count: 1 });
// ACT
const result = await executeSelectMany(mockExecuteFunctions, 0, dataStoreProxy);
// ASSERT
expect(result).toEqual([{ json: { id: 1, name: 'Anne-Marie' } }]);
});
it('should handle multiple conditions with ANY_FILTER (OR logic - matches records satisfying either condition)', async () => {
// ARRANGE
filters = [
{ condition: 'eq', keyName: 'status', keyValue: 'active' },
{ condition: 'gt', keyName: 'age', keyValue: 50 },
];
getManyRowsAndCount.mockReturnValue({
data: [{ id: 1, status: 'active', age: 25 }],
count: 1,
});
// ACT
const result = await executeSelectMany(mockExecuteFunctions, 0, dataStoreProxy);
// ASSERT
expect(result).toEqual([{ json: { id: 1, status: 'active', age: 25 } }]);
});
it('should handle multiple conditions with ALL_FILTERS (AND logic - matches records satisfying all conditions)', async () => {
// ARRANGE
filters = [
{ condition: 'eq', keyName: 'status', keyValue: 'active' },
{ condition: 'gte', keyName: 'age', keyValue: 21 },
];
mockExecuteFunctions.getNodeParameter = jest.fn().mockImplementation((field) => {
switch (field) {
case DATA_TABLE_ID_FIELD:
return dataTableId;
case 'filters.conditions':
return filters;
case 'matchType':
return ALL_FILTERS;
}
});
getManyRowsAndCount.mockReturnValue({
data: [{ id: 1, status: 'active', age: 25 }],
count: 1,
});
// ACT
const result = await executeSelectMany(mockExecuteFunctions, 0, dataStoreProxy);
// ASSERT
expect(result).toEqual([{ json: { id: 1, status: 'active', age: 25 } }]);
});
it('should handle ALL_FILTERS excluding records that match only one condition (proves AND logic)', async () => {
// ARRANGE
filters = [
{ condition: 'eq', keyName: 'status', keyValue: 'inactive' },
{ condition: 'gte', keyName: 'age', keyValue: 21 },
];
mockExecuteFunctions.getNodeParameter = jest.fn().mockImplementation((field) => {
switch (field) {
case DATA_TABLE_ID_FIELD:
return dataTableId;
case 'filters.conditions':
return filters;
case 'matchType':
return ALL_FILTERS;
}
});
getManyRowsAndCount.mockReturnValue({
data: [],
count: 0,
});
// ACT
const result = await executeSelectMany(mockExecuteFunctions, 0, dataStoreProxy);
// ASSERT
expect(result).toEqual([]);
});
it('should handle ANY_FILTER including records that match only one condition (proves OR logic)', async () => {
// ARRANGE
filters = [
{ condition: 'eq', keyName: 'status', keyValue: 'inactive' },
{ condition: 'gte', keyName: 'age', keyValue: 21 },
];
mockExecuteFunctions.getNodeParameter = jest.fn().mockImplementation((field) => {
switch (field) {
case DATA_TABLE_ID_FIELD:
return dataTableId;
case 'filters.conditions':
return filters;
case 'matchType':
return ANY_FILTER;
}
});
getManyRowsAndCount.mockReturnValue({
data: [{ id: 1, status: 'active', age: 25 }],
count: 1,
});
// ACT
const result = await executeSelectMany(mockExecuteFunctions, 0, dataStoreProxy);
// ASSERT
expect(result).toEqual([{ json: { id: 1, status: 'active', age: 25 } }]);
});
});
});
});

View File

@@ -45,7 +45,7 @@ export type ListDataStoreContentFilter = {
type: 'and' | 'or';
filters: Array<{
columnName: string;
condition: 'eq' | 'neq';
condition: 'eq' | 'neq' | 'like' | 'ilike' | 'gt' | 'gte' | 'lt' | 'lte';
value: DataStoreColumnJsType;
}>;
};