diff --git a/packages/nodes-base/nodes/DataTable/common/utils.ts b/packages/nodes-base/nodes/DataTable/common/utils.ts index e52b9d1425..ba47f8c0c7 100644 --- a/packages/nodes-base/nodes/DataTable/common/utils.ts +++ b/packages/nodes-base/nodes/DataTable/common/utils.ts @@ -1,3 +1,4 @@ +import { DateTime } from 'luxon'; import type { IDataObject, INode, @@ -6,6 +7,7 @@ import type { IDataStoreProjectService, IExecuteFunctions, ILoadOptionsFunctions, + DataStoreColumnJsType, } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; @@ -13,6 +15,14 @@ import type { FieldEntry, FilterType } from './constants'; import { ALL_FILTERS, ANY_FILTER } from './constants'; import { DATA_TABLE_ID_FIELD } from './fields'; +type DateLike = { toISOString: () => string }; + +function isDateLike(v: unknown): v is DateLike { + return ( + v !== null && typeof v === 'object' && 'toISOString' in v && typeof v.toISOString === 'function' + ); +} + // We need two functions here since the available getNodeParameter // overloads vary with the index export async function getDataTableProxyExecute( @@ -87,9 +97,13 @@ export function isFieldArray(value: unknown): value is FieldEntry[] { ); } -export function dataObjectToApiInput(data: IDataObject, node: INode, row: number) { +export function dataObjectToApiInput( + data: IDataObject, + node: INode, + row: number, +): Record { return Object.fromEntries( - Object.entries(data).map(([k, v]) => { + Object.entries(data).map(([k, v]): [string, DataStoreColumnJsType] => { if (v === undefined || v === null) return [k, null]; if (Array.isArray(v)) { @@ -99,7 +113,28 @@ export function dataObjectToApiInput(data: IDataObject, node: INode, row: number ); } - if (!(v instanceof Date) && typeof v === 'object') { + if (v instanceof Date) { + return [k, v]; + } + + if (typeof v === 'object') { + // Luxon DateTime + if (DateTime.isDateTime(v)) { + return [k, v.toJSDate()]; + } + + if (isDateLike(v)) { + try { + const dateObj = new Date(v.toISOString()); + if (isNaN(dateObj.getTime())) { + throw new Error('Invalid date'); + } + return [k, dateObj]; + } catch { + // Fall through + } + } + throw new NodeOperationError( node, `unexpected object input '${JSON.stringify(v)}' in row ${row}`, diff --git a/packages/nodes-base/nodes/DataTable/test/common/utils.test.ts b/packages/nodes-base/nodes/DataTable/test/common/utils.test.ts new file mode 100644 index 0000000000..46fca52681 --- /dev/null +++ b/packages/nodes-base/nodes/DataTable/test/common/utils.test.ts @@ -0,0 +1,203 @@ +import { DateTime } from 'luxon'; +import type { INode } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { dataObjectToApiInput } from '../../common/utils'; + +describe('dataObjectToApiInput', () => { + const mockNode: INode = { + id: 'test-node', + name: 'Test Node', + type: 'test', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }; + + describe('primitive types', () => { + it('should handle string values', () => { + const input = { name: 'John', email: 'john@example.com' }; + const result = dataObjectToApiInput(input, mockNode, 0); + + expect(result).toEqual({ + name: 'John', + email: 'john@example.com', + }); + }); + + it('should handle number values', () => { + const input = { age: 25, price: 99.99, count: 0 }; + const result = dataObjectToApiInput(input, mockNode, 0); + + expect(result).toEqual({ + age: 25, + price: 99.99, + count: 0, + }); + }); + + it('should handle boolean values', () => { + const input = { isActive: true, isDeleted: false }; + const result = dataObjectToApiInput(input, mockNode, 0); + + expect(result).toEqual({ + isActive: true, + isDeleted: false, + }); + }); + }); + + describe('null and undefined values', () => { + it('should convert null values to null', () => { + const input = { field1: null, field2: 'value' }; + const result = dataObjectToApiInput(input, mockNode, 0); + + expect(result).toEqual({ + field1: null, + field2: 'value', + }); + }); + + it('should convert undefined values to null', () => { + const input = { field1: undefined, field2: 'value' }; + const result = dataObjectToApiInput(input, mockNode, 0); + + expect(result).toEqual({ + field1: null, + field2: 'value', + }); + }); + }); + + describe('Date objects', () => { + it('should handle JavaScript Date objects', () => { + const testDate = new Date('2025-09-01T12:00:00.000Z'); + const input = { createdAt: testDate, name: 'test' }; + const result = dataObjectToApiInput(input, mockNode, 0); + + expect(result).toEqual({ + createdAt: testDate, + name: 'test', + }); + }); + }); + + describe('Luxon DateTime objects', () => { + it('should convert Luxon DateTime objects to JavaScript Date', () => { + const luxonDateTime = DateTime.fromISO('2025-09-01T12:00:00.000Z'); + const input = { createdAt: luxonDateTime, name: 'test' }; + const result = dataObjectToApiInput(input, mockNode, 0); + + expect(result.name).toBe('test'); + expect(result.createdAt).toBeInstanceOf(Date); + expect((result.createdAt as Date).toISOString()).toBe('2025-09-01T12:00:00.000Z'); + }); + }); + + describe('date-like objects', () => { + it('should convert objects with toISOString method to Date', () => { + const dateLikeObject = { + toISOString: () => '2025-09-01T12:00:00.000Z', + }; + const input = { createdAt: dateLikeObject, name: 'test' }; + const result = dataObjectToApiInput(input, mockNode, 0); + + expect(result.name).toBe('test'); + expect(result.createdAt).toBeInstanceOf(Date); + expect((result.createdAt as Date).toISOString()).toBe('2025-09-01T12:00:00.000Z'); + }); + + it('should handle date-like objects where toISOString throws', () => { + const dateLikeObject = { + toISOString: () => { + throw new Error('toISOString failed'); + }, + }; + const input = { createdAt: dateLikeObject, name: 'test' }; + + expect(() => dataObjectToApiInput(input, mockNode, 0)).toThrow(NodeOperationError); + expect(() => dataObjectToApiInput(input, mockNode, 0)).toThrow('unexpected object input'); + }); + }); + + describe('error cases', () => { + it('should throw error for array inputs', () => { + const input = { items: ['item1', 'item2'] }; + + expect(() => dataObjectToApiInput(input, mockNode, 0)).toThrow(NodeOperationError); + expect(() => dataObjectToApiInput(input, mockNode, 0)).toThrow( + 'unexpected array input \'["item1","item2"]\' in row 0', + ); + }); + + it('should throw error for plain objects', () => { + const input = { metadata: { key: 'value' } }; + + expect(() => dataObjectToApiInput(input, mockNode, 0)).toThrow(NodeOperationError); + expect(() => dataObjectToApiInput(input, mockNode, 0)).toThrow( + 'unexpected object input \'{"key":"value"}\' in row 0', + ); + }); + + it('should throw error for objects without toISOString method', () => { + const input = { config: { setting1: true, setting2: 'value' } }; + + expect(() => dataObjectToApiInput(input, mockNode, 0)).toThrow(NodeOperationError); + expect(() => dataObjectToApiInput(input, mockNode, 0)).toThrow('unexpected object input'); + }); + + test('dataObjectToApiInput throws on invalid date-like object', () => { + const dateLikeObject = { + toISOString: () => 'not-a-date', + }; + const input = { createdAt: dateLikeObject, name: 'test' }; + + expect(() => dataObjectToApiInput(input, mockNode, 0)).toThrow(NodeOperationError); + expect(() => dataObjectToApiInput(input, mockNode, 0)).toThrow( + "unexpected object input '{}' in row 0", + ); + }); + + it('should include correct row number in error message', () => { + const input = { items: ['item1'] }; + + expect(() => dataObjectToApiInput(input, mockNode, 5)).toThrow( + 'unexpected array input \'["item1"]\' in row 5', + ); + }); + }); + + describe('mixed data types', () => { + it('should handle mixed valid data types', () => { + const testDate = new Date('2025-09-01T12:00:00.000Z'); + const luxonDateTime = DateTime.fromISO('2025-09-02T10:30:00.000Z'); + const dateLikeObject = { + toISOString: () => '2025-09-03T08:15:00.000Z', + }; + + const input = { + name: 'John Doe', + age: 30, + isActive: true, + createdAt: testDate, + updatedAt: luxonDateTime, + scheduledAt: dateLikeObject, + deletedAt: null, + description: undefined, + }; + + const result = dataObjectToApiInput(input, mockNode, 0); + + expect(result.name).toBe('John Doe'); + expect(result.age).toBe(30); + expect(result.isActive).toBe(true); + expect(result.createdAt).toBe(testDate); + expect(result.updatedAt).toBeInstanceOf(Date); + expect((result.updatedAt as Date).toISOString()).toBe('2025-09-02T10:30:00.000Z'); + expect(result.scheduledAt).toBeInstanceOf(Date); + expect((result.scheduledAt as Date).toISOString()).toBe('2025-09-03T08:15:00.000Z'); + expect(result.deletedAt).toBe(null); + expect(result.description).toBe(null); + }); + }); +});