fix(Data Table Node): Date handling (no-changelog) (#20437)

This commit is contained in:
Daria 2025-10-07 13:59:42 +03:00 committed by GitHub
parent 49064fc6da
commit f68656d6ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 189 additions and 22 deletions

View File

@ -1121,10 +1121,10 @@ describe('dataTable filters', () => {
dataTableId = id;
const rows = [
{ name: 'Task1', registeredAt: new Date('2023-12-31') },
{ name: 'Task2', registeredAt: new Date('2024-01-01') },
{ name: 'Task3', registeredAt: new Date('2024-01-02') },
{ name: 'Task4', registeredAt: new Date('2024-01-03') },
{ name: 'Task1', registeredAt: new Date('2023-12-31T23:59:59.999Z') },
{ name: 'Task2', registeredAt: new Date('2024-01-01T10:00:00.000Z') },
{ name: 'Task3', registeredAt: new Date('2024-01-02T00:00:00.000Z') },
{ name: 'Task4', registeredAt: new Date('2024-01-02T09:59:00.000Z') },
];
await dataTableService.insertRows(dataTableId, project.id, rows);
@ -1132,7 +1132,7 @@ describe('dataTable filters', () => {
it("retrieves rows with 'greater than' date filter correctly", async () => {
// ACT
const baseDate = new Date('2024-01-01');
const baseDate = new Date('2024-01-01T12:00:00.000Z');
const result = await dataTableService.getManyRowsAndCount(dataTableId, project.id, {
filter: {
type: 'and',
@ -1166,6 +1166,59 @@ describe('dataTable filters', () => {
expect.objectContaining({ name: 'Task3' }),
]);
});
it('filters by system createdAt column correctly', async () => {
// ARRANGE
const inserted = await dataTableService.insertRows(
dataTableId,
project.id,
[{ name: 'TestRow' }],
'all',
);
const createdAtTimestamp = inserted[0].createdAt;
const midnight = new Date(createdAtTimestamp);
midnight.setHours(0, 0, 0, 0);
// ACT - Check the row is not returned if filtered before midnight
const beforeMidnightResult = await dataTableService.getManyRowsAndCount(
dataTableId,
project.id,
{
filter: {
type: 'and',
filters: [{ columnName: 'createdAt', value: midnight, condition: 'lt' }],
},
},
);
// ASSERT
expect(beforeMidnightResult.data.some((row) => row.name === 'TestRow')).toBe(false);
// ACT - Check the row is returned when using lte on the exact timestamp
const result = await dataTableService.getManyRowsAndCount(dataTableId, project.id, {
filter: {
type: 'and',
filters: [{ columnName: 'createdAt', value: createdAtTimestamp, condition: 'lte' }],
},
});
// ASSERT
expect(result.count).toBeGreaterThanOrEqual(1);
expect(result.data.some((row) => row.name === 'TestRow')).toBe(true);
// ACT - - Check the row is returned when using lt on the exact timestamp
const resultLt = await dataTableService.getManyRowsAndCount(dataTableId, project.id, {
filter: {
type: 'and',
filters: [{ columnName: 'createdAt', value: createdAtTimestamp, condition: 'lt' }],
},
});
// ASSERT
expect(resultLt.data.some((row) => row.name === 'TestRow')).toBe(false);
});
});
describe('null value validation', () => {

View File

@ -1,4 +1,4 @@
import { DATA_TABLE_SYSTEM_COLUMNS, NodeOperationError } from 'n8n-workflow';
import { DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP, NodeOperationError } from 'n8n-workflow';
import type {
DataTableFilter,
DataTableRowReturn,
@ -6,6 +6,7 @@ import type {
IDisplayOptions,
IExecuteFunctions,
INodeProperties,
DataTableColumnType,
} from 'n8n-workflow';
import { ALL_CONDITIONS, ANY_CONDITION, ROWS_LIMIT_DEFAULT, type FilterType } from './constants';
@ -117,15 +118,19 @@ export async function getSelectFilter(
}
// Validate filter conditions against current table schema
let allColumnsWithTypes: Record<string, DataTableColumnType> = DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP;
if (fields.length > 0) {
const dataTableProxy = await getDataTableProxyExecute(ctx, index);
const availableColumns = await dataTableProxy.getColumns();
const allColumns = new Set([
...DATA_TABLE_SYSTEM_COLUMNS,
...availableColumns.map((col) => col.name),
]);
const invalidConditions = fields.filter((field) => !allColumns.has(field.keyName));
// Add system columns with their types
allColumnsWithTypes = {
...DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP,
...Object.fromEntries(availableColumns.map((col) => [col.name, col.type])),
};
const invalidConditions = fields.filter((field) => !allColumnsWithTypes[field.keyName]);
if (invalidConditions.length > 0) {
const invalidColumnNames = invalidConditions.map((c) => c.keyName).join(', ');
@ -138,7 +143,7 @@ export async function getSelectFilter(
}
}
return buildGetManyFilter(fields, matchType);
return buildGetManyFilter(fields, matchType, allColumnsWithTypes, node);
}
export async function executeSelectMany(

View File

@ -8,6 +8,7 @@ import type {
IExecuteFunctions,
ILoadOptionsFunctions,
DataTableColumnJsType,
DataTableColumnType,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
@ -86,6 +87,8 @@ export function isMatchType(obj: unknown): obj is FilterType {
export function buildGetManyFilter(
fieldEntries: FieldEntry[],
matchType: FilterType,
columnTypeMap: Record<string, DataTableColumnType>,
node: INode,
): DataTableFilter {
const filters = fieldEntries.map((x) => {
switch (x.condition) {
@ -113,12 +116,27 @@ export function buildGetManyFilter(
condition: 'eq' as const,
value: false,
};
default:
default: {
let value = x.keyValue;
const columnType = columnTypeMap[x.keyName];
// Convert ISO date strings to Date objects for date columns
if (columnType === 'date' && typeof value === 'string') {
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
throw new NodeOperationError(
node,
`Invalid date string '${value}' for column '${x.keyName}'`,
);
}
value = parsed;
}
return {
columnName: x.keyName,
condition: x.condition ?? 'eq',
value: x.keyValue,
value,
};
}
}
});
return { type: matchType === ALL_CONDITIONS ? 'and' : 'or', filters };

View File

@ -2,7 +2,7 @@ import { DateTime } from 'luxon';
import type { INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { ANY_CONDITION, ALL_CONDITIONS } from '../../common/constants';
import { ANY_CONDITION, ALL_CONDITIONS, type FieldEntry } from '../../common/constants';
import { dataObjectToApiInput, buildGetManyFilter } from '../../common/utils';
const mockNode: INode = {
@ -210,7 +210,7 @@ describe('buildGetManyFilter', () => {
{ keyName: 'name', condition: 'isEmpty' as const, keyValue: 'ignored' },
];
const result = buildGetManyFilter(fieldEntries, ALL_CONDITIONS);
const result = buildGetManyFilter(fieldEntries, ALL_CONDITIONS, { name: 'string' }, mockNode);
expect(result).toEqual({
type: 'and',
@ -229,7 +229,7 @@ describe('buildGetManyFilter', () => {
{ keyName: 'email', condition: 'isNotEmpty' as const, keyValue: 'ignored' },
];
const result = buildGetManyFilter(fieldEntries, ANY_CONDITION);
const result = buildGetManyFilter(fieldEntries, ANY_CONDITION, { email: 'string' }, mockNode);
expect(result).toEqual({
type: 'or',
@ -250,7 +250,16 @@ describe('buildGetManyFilter', () => {
{ keyName: 'phone', condition: 'isNotEmpty' as const, keyValue: 'ignored' },
];
const result = buildGetManyFilter(fieldEntries, ALL_CONDITIONS);
const result = buildGetManyFilter(
fieldEntries,
ALL_CONDITIONS,
{
name: 'string',
email: 'string',
phone: 'string',
},
mockNode,
);
expect(result).toEqual({
type: 'and',
@ -281,7 +290,12 @@ describe('buildGetManyFilter', () => {
{ keyName: 'isActive', condition: 'isTrue' as const, keyValue: 'ignored' },
];
const result = buildGetManyFilter(fieldEntries, ALL_CONDITIONS);
const result = buildGetManyFilter(
fieldEntries,
ALL_CONDITIONS,
{ isActive: 'boolean' },
mockNode,
);
expect(result).toEqual({
type: 'and',
@ -300,7 +314,12 @@ describe('buildGetManyFilter', () => {
{ keyName: 'email', condition: 'isFalse' as const, keyValue: 'ignored' },
];
const result = buildGetManyFilter(fieldEntries, ANY_CONDITION);
const result = buildGetManyFilter(
fieldEntries,
ANY_CONDITION,
{ email: 'boolean' },
mockNode,
);
expect(result).toEqual({
type: 'or',
@ -321,7 +340,16 @@ describe('buildGetManyFilter', () => {
{ keyName: 'isDeleted', condition: 'isFalse' as const, keyValue: 'ignored' },
];
const result = buildGetManyFilter(fieldEntries, ALL_CONDITIONS);
const result = buildGetManyFilter(
fieldEntries,
ALL_CONDITIONS,
{
name: 'string',
isActive: 'boolean',
isDeleted: 'boolean',
},
mockNode,
);
expect(result).toEqual({
type: 'and',
@ -352,7 +380,15 @@ describe('buildGetManyFilter', () => {
{ keyName: 'name', condition: 'like' as const, keyValue: '%john%' },
];
const result = buildGetManyFilter(fieldEntries, ANY_CONDITION);
const result = buildGetManyFilter(
fieldEntries,
ANY_CONDITION,
{
age: 'number',
name: 'string',
},
mockNode,
);
expect(result).toEqual({
type: 'or',
@ -370,4 +406,59 @@ describe('buildGetManyFilter', () => {
],
});
});
describe('date handling in filters', () => {
it('should pass Date objects through unchanged', () => {
const testDate = new Date('2025-10-06T08:14:42.274Z');
const fieldEntries: FieldEntry[] = [
{ keyName: 'createdAt', condition: 'lte', keyValue: testDate },
];
const result = buildGetManyFilter(
fieldEntries,
ALL_CONDITIONS,
{ createdAt: 'date' },
mockNode,
);
expect(result).toEqual({
type: 'and',
filters: [
{
columnName: 'createdAt',
condition: 'lte',
value: testDate,
},
],
});
});
it('should convert ISO date strings to Date objects', () => {
const dateString = '2025-10-06T08:14:42.274Z';
const fieldEntries: FieldEntry[] = [
{ keyName: 'createdAt', condition: 'lte', keyValue: dateString },
];
const result = buildGetManyFilter(
fieldEntries,
ALL_CONDITIONS,
{ createdAt: 'date' },
mockNode,
);
expect(result.filters[0].value).toBeInstanceOf(Date);
expect((result.filters[0].value as Date).toISOString()).toBe(dateString);
});
it('should throw an Error for invalid date strings', () => {
const invalidDateString = 'invalid-date';
const fieldEntries: FieldEntry[] = [
{ keyName: 'createdAt', condition: 'lte', keyValue: invalidDateString },
];
expect(() =>
buildGetManyFilter(fieldEntries, ALL_CONDITIONS, { createdAt: 'date' }, mockNode),
).toThrowError(`Invalid date string '${invalidDateString}' for column 'createdAt'`);
});
});
});