diff --git a/packages/cli/src/modules/data-table/__tests__/data-table-filters.integration.test.ts b/packages/cli/src/modules/data-table/__tests__/data-table-filters.integration.test.ts index daa56afe008..bb58355e432 100644 --- a/packages/cli/src/modules/data-table/__tests__/data-table-filters.integration.test.ts +++ b/packages/cli/src/modules/data-table/__tests__/data-table-filters.integration.test.ts @@ -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', () => { diff --git a/packages/nodes-base/nodes/DataTable/common/selectMany.ts b/packages/nodes-base/nodes/DataTable/common/selectMany.ts index 32ea62c6342..dcaf64feb3d 100644 --- a/packages/nodes-base/nodes/DataTable/common/selectMany.ts +++ b/packages/nodes-base/nodes/DataTable/common/selectMany.ts @@ -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 = 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( diff --git a/packages/nodes-base/nodes/DataTable/common/utils.ts b/packages/nodes-base/nodes/DataTable/common/utils.ts index c19e0c5df6a..e1b92a1392b 100644 --- a/packages/nodes-base/nodes/DataTable/common/utils.ts +++ b/packages/nodes-base/nodes/DataTable/common/utils.ts @@ -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, + 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 }; diff --git a/packages/nodes-base/nodes/DataTable/test/common/utils.test.ts b/packages/nodes-base/nodes/DataTable/test/common/utils.test.ts index 6fe2d29655e..181f768b601 100644 --- a/packages/nodes-base/nodes/DataTable/test/common/utils.test.ts +++ b/packages/nodes-base/nodes/DataTable/test/common/utils.test.ts @@ -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'`); + }); + }); });