diff --git a/packages/nodes-base/nodes/DataTable/DataTable.node.ts b/packages/nodes-base/nodes/DataTable/DataTable.node.ts index a0ea01eab31..6d2453e3f66 100644 --- a/packages/nodes-base/nodes/DataTable/DataTable.node.ts +++ b/packages/nodes-base/nodes/DataTable/DataTable.node.ts @@ -17,7 +17,7 @@ export class DataTable implements INodeType { icon: 'fa:table', iconColor: 'orange-red', group: ['input', 'transform'], - version: 1, + version: [1, 1.1], subtitle: '={{$parameter["action"]}}', description: 'Permanently save data across workflow executions in a table', defaults: { diff --git a/packages/nodes-base/nodes/DataTable/common/constants.ts b/packages/nodes-base/nodes/DataTable/common/constants.ts index c0421567066..28d2a08532f 100644 --- a/packages/nodes-base/nodes/DataTable/common/constants.ts +++ b/packages/nodes-base/nodes/DataTable/common/constants.ts @@ -1,4 +1,4 @@ -import type { DataTableColumnJsType } from 'n8n-workflow'; +import type { DataTableColumnJsType, DataTableColumnType } from 'n8n-workflow'; export const ANY_CONDITION = 'anyCondition'; export const ALL_CONDITIONS = 'allConditions'; @@ -7,13 +7,17 @@ export const ROWS_LIMIT_DEFAULT = 50; export type FilterType = typeof ANY_CONDITION | typeof ALL_CONDITIONS; +export type KeyNameType = `${string} (${DataTableColumnType})`; + export type FieldEntry = | { - keyName: string; + keyName: KeyNameType; condition: 'isEmpty' | 'isNotEmpty' | 'isTrue' | 'isFalse'; + path?: string; } | { - keyName: string; + keyName: KeyNameType; condition?: 'eq' | 'neq' | 'like' | 'ilike' | 'gt' | 'gte' | 'lt' | 'lte'; keyValue: DataTableColumnJsType; + path?: string; }; diff --git a/packages/nodes-base/nodes/DataTable/common/methods.ts b/packages/nodes-base/nodes/DataTable/common/methods.ts index 22bdfdaa674..a9f08e60b2b 100644 --- a/packages/nodes-base/nodes/DataTable/common/methods.ts +++ b/packages/nodes-base/nodes/DataTable/common/methods.ts @@ -58,9 +58,12 @@ export async function getDataTableColumns(this: ILoadOptionsFunctions) { const columns = await proxy.getColumns(); for (const column of columns) { + // From version 1.1 we start encoding the type as metadata on the value here + // to support showing the `path` field for json columns + const value = this.getNode().typeVersion > 1 ? `${column.name} (${column.type})` : column.name; returnData.push({ name: `${column.name} (${column.type})`, - value: column.name, + value, type: column.type, }); } @@ -72,7 +75,8 @@ export async function getConditionsForColumn(this: ILoadOptionsFunctions) { if (!proxy) { return []; } - const keyName = this.getCurrentNodeParameter('&keyName') as string; + const rawKeyName = this.getCurrentNodeParameter('&keyName') as string; + const keyName = rawKeyName?.split(' ')[0]; const nullConditions: INodePropertyOptions[] = [ { name: 'Is Empty', value: 'isEmpty' }, @@ -125,6 +129,12 @@ export async function getConditionsForColumn(this: ILoadOptionsFunctions) { const conditions: INodePropertyOptions[] = []; + if (type === 'json') { + conditions.push.apply(conditions, booleanConditions); + conditions.push.apply(conditions, equalsConditions); + conditions.push.apply(conditions, stringConditions); + } + if (type === 'boolean') { conditions.push.apply(conditions, booleanConditions); } @@ -156,7 +166,7 @@ export async function getDataTables(this: ILoadOptionsFunctions): Promise 'a.b[0].c'.", + displayOptions: { + show: { + keyName: [{ _cnd: { includes: '(json)' } }], + }, + }, + }, ], }, ], @@ -130,10 +143,12 @@ export async function getSelectFilter( ...Object.fromEntries(availableColumns.map((col) => [col.name, col.type])), }; - const invalidConditions = fields.filter((field) => !allColumnsWithTypes[field.keyName]); + const invalidConditions = fields.filter( + (field) => !allColumnsWithTypes[field.keyName.split(' ')[0]], + ); if (invalidConditions.length > 0) { - const invalidColumnNames = invalidConditions.map((c) => c.keyName).join(', '); + const invalidColumnNames = invalidConditions.map((c) => c.keyName.split(' ')[0]).join(', '); throw new NodeOperationError( node, `Filter validation failed: Column(s) "${invalidColumnNames}" do not exist in the selected table. ` + diff --git a/packages/nodes-base/nodes/DataTable/common/utils.ts b/packages/nodes-base/nodes/DataTable/common/utils.ts index e1b92a1392b..05508474717 100644 --- a/packages/nodes-base/nodes/DataTable/common/utils.ts +++ b/packages/nodes-base/nodes/DataTable/common/utils.ts @@ -91,34 +91,38 @@ export function buildGetManyFilter( node: INode, ): DataTableFilter { const filters = fieldEntries.map((x) => { + const common = { + columnName: x.keyName.split(' ')[0], + path: x.path, + }; switch (x.condition) { case 'isEmpty': return { - columnName: x.keyName, + ...common, condition: 'eq' as const, value: null, }; case 'isNotEmpty': return { - columnName: x.keyName, + ...common, condition: 'neq' as const, value: null, }; case 'isTrue': return { - columnName: x.keyName, + ...common, condition: 'eq' as const, value: true, }; case 'isFalse': return { - columnName: x.keyName, + ...common, condition: 'eq' as const, value: false, }; default: { let value = x.keyValue; - const columnType = columnTypeMap[x.keyName]; + const columnType = columnTypeMap[common.columnName]; // Convert ISO date strings to Date objects for date columns if (columnType === 'date' && typeof value === 'string') { @@ -126,13 +130,13 @@ export function buildGetManyFilter( if (isNaN(parsed.getTime())) { throw new NodeOperationError( node, - `Invalid date string '${value}' for column '${x.keyName}'`, + `Invalid date string '${value}' for column '${common.columnName}'`, ); } value = parsed; } return { - columnName: x.keyName, + ...common, condition: x.condition ?? 'eq', value, }; @@ -186,10 +190,7 @@ export function dataObjectToApiInput( } } - throw new NodeOperationError( - node, - `unexpected object input '${JSON.stringify(v)}' in row ${row}`, - ); + return [k, dataObjectToApiInput(v as IDataObject, node, row)]; } return [k, v]; diff --git a/packages/nodes-base/nodes/DataTable/test/common/selectMany.test.ts b/packages/nodes-base/nodes/DataTable/test/common/selectMany.test.ts index fb1b6788845..48a04f61a7b 100644 --- a/packages/nodes-base/nodes/DataTable/test/common/selectMany.test.ts +++ b/packages/nodes-base/nodes/DataTable/test/common/selectMany.test.ts @@ -24,7 +24,7 @@ describe('selectMany utils', () => { filters = [ { condition: 'eq', - keyName: 'id', + keyName: 'id (string) (string)', keyValue: 1, }, ]; @@ -132,7 +132,7 @@ describe('selectMany utils', () => { describe('filter conditions', () => { it('should handle "eq" condition', async () => { // ARRANGE - filters = [{ condition: 'eq', keyName: 'name', keyValue: 'John' }]; + filters = [{ condition: 'eq', keyName: 'name (string)', keyValue: 'John' }]; getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, name: 'John' }], count: 1 }); // ACT @@ -144,7 +144,7 @@ describe('selectMany utils', () => { it('should handle "neq" condition', async () => { // ARRANGE - filters = [{ condition: 'neq', keyName: 'name', keyValue: 'John' }]; + filters = [{ condition: 'neq', keyName: 'name (string)', keyValue: 'John' }]; getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, name: 'Jane' }], count: 1 }); // ACT @@ -156,7 +156,7 @@ describe('selectMany utils', () => { it('should handle "gt" condition with numbers', async () => { // ARRANGE - filters = [{ condition: 'gt', keyName: 'age', keyValue: 25 }]; + filters = [{ condition: 'gt', keyName: 'age (number)', keyValue: 25 }]; getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, age: 30 }], count: 1 }); // ACT @@ -168,7 +168,7 @@ describe('selectMany utils', () => { it('should handle "gte" condition with numbers', async () => { // ARRANGE - filters = [{ condition: 'gte', keyName: 'age', keyValue: 25 }]; + filters = [{ condition: 'gte', keyName: 'age (number)', keyValue: 25 }]; getManyRowsAndCount.mockReturnValue({ data: [ { id: 1, age: 25 }, @@ -186,7 +186,7 @@ describe('selectMany utils', () => { it('should handle "lt" condition with numbers', async () => { // ARRANGE - filters = [{ condition: 'lt', keyName: 'age', keyValue: 30 }]; + filters = [{ condition: 'lt', keyName: 'age (number)', keyValue: 30 }]; getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, age: 25 }], count: 1 }); // ACT @@ -198,7 +198,7 @@ describe('selectMany utils', () => { it('should handle "lte" condition with numbers', async () => { // ARRANGE - filters = [{ condition: 'lte', keyName: 'age', keyValue: 30 }]; + filters = [{ condition: 'lte', keyName: 'age (number)', keyValue: 30 }]; getManyRowsAndCount.mockReturnValue({ data: [ { id: 1, age: 25 }, @@ -216,7 +216,7 @@ describe('selectMany utils', () => { it('should handle "like" condition with pattern matching', async () => { // ARRANGE - filters = [{ condition: 'like', keyName: 'name', keyValue: '%Mar%' }]; + filters = [{ condition: 'like', keyName: 'name (string)', keyValue: '%Mar%' }]; getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, name: 'Anne-Marie' }], count: 1 }); // ACT @@ -228,7 +228,7 @@ describe('selectMany utils', () => { it('should handle "ilike" condition with case-insensitive pattern matching', async () => { // ARRANGE - filters = [{ condition: 'ilike', keyName: 'name', keyValue: '%mar%' }]; + filters = [{ condition: 'ilike', keyName: 'name (string)', keyValue: '%mar%' }]; getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, name: 'Anne-Marie' }], count: 1 }); // ACT @@ -241,8 +241,8 @@ describe('selectMany utils', () => { it('should handle multiple conditions with ANY_CONDITION (OR logic - matches records satisfying either condition)', async () => { // ARRANGE filters = [ - { condition: 'eq', keyName: 'status', keyValue: 'active' }, - { condition: 'gt', keyName: 'age', keyValue: 50 }, + { condition: 'eq', keyName: 'status (string)', keyValue: 'active' }, + { condition: 'gt', keyName: 'age (number)', keyValue: 50 }, ]; getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, status: 'active', age: 25 }], @@ -259,8 +259,8 @@ describe('selectMany utils', () => { it('should handle multiple conditions with ALL_CONDITIONS (AND logic - matches records satisfying all conditions)', async () => { // ARRANGE filters = [ - { condition: 'eq', keyName: 'status', keyValue: 'active' }, - { condition: 'gte', keyName: 'age', keyValue: 21 }, + { condition: 'eq', keyName: 'status (string)', keyValue: 'active' }, + { condition: 'gte', keyName: 'age (number)', keyValue: 21 }, ]; mockExecuteFunctions.getNodeParameter = jest.fn().mockImplementation((field) => { switch (field) { @@ -287,8 +287,8 @@ describe('selectMany utils', () => { it('should handle ALL_CONDITIONS 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 }, + { condition: 'eq', keyName: 'status (string)', keyValue: 'inactive' }, + { condition: 'gte', keyName: 'age (number)', keyValue: 21 }, ]; mockExecuteFunctions.getNodeParameter = jest.fn().mockImplementation((field) => { switch (field) { @@ -315,8 +315,8 @@ describe('selectMany utils', () => { it('should handle ANY_CONDITION 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 }, + { condition: 'eq', keyName: 'status (string)', keyValue: 'inactive' }, + { condition: 'gte', keyName: 'age (number)', keyValue: 21 }, ]; mockExecuteFunctions.getNodeParameter = jest.fn().mockImplementation((field) => { switch (field) { @@ -346,8 +346,8 @@ describe('selectMany utils', () => { it('should validate filter conditions against table schema', async () => { // ARRANGE filters = [ - { condition: 'eq', keyName: 'name', keyValue: 'John' }, // Valid column - { condition: 'eq', keyName: 'invalid_column', keyValue: 'test' }, // Invalid column + { condition: 'eq', keyName: 'name (string)', keyValue: 'John' }, // Valid column + { condition: 'eq', keyName: 'invalid_column (string)', keyValue: 'test' }, // Invalid column ]; // ACT & ASSERT @@ -364,8 +364,8 @@ describe('selectMany utils', () => { it('should allow system columns in filter conditions', async () => { // ARRANGE filters = [ - { condition: 'eq', keyName: 'id', keyValue: 1 }, // System column - { condition: 'neq', keyName: 'createdAt', keyValue: null }, // System column + { condition: 'eq', keyName: 'id (string)', keyValue: 1 }, // System column + { condition: 'neq', keyName: 'createdAt (date)', keyValue: null }, // System column ]; // ACT @@ -379,8 +379,8 @@ describe('selectMany utils', () => { it('should allow combination of system and custom columns', async () => { // ARRANGE filters = [ - { condition: 'eq', keyName: 'id', keyValue: 1 }, // System column - { condition: 'eq', keyName: 'name', keyValue: 'John' }, // Custom column + { condition: 'eq', keyName: 'id (string)', keyValue: 1 }, // System column + { condition: 'eq', keyName: 'name (string)', keyValue: 'John' }, // Custom column ]; // ACT @@ -406,8 +406,8 @@ describe('selectMany utils', () => { it('should report multiple invalid columns in error message', async () => { // ARRANGE filters = [ - { condition: 'eq', keyName: 'invalid1', keyValue: 'test1' }, - { condition: 'eq', keyName: 'invalid2', keyValue: 'test2' }, + { condition: 'eq', keyName: 'invalid1 (string)', keyValue: 'test1' }, + { condition: 'eq', keyName: 'invalid2 (string)', keyValue: 'test2' }, ]; // ACT & ASSERT 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 181f768b601..591d3041a4a 100644 --- a/packages/nodes-base/nodes/DataTable/test/common/utils.test.ts +++ b/packages/nodes-base/nodes/DataTable/test/common/utils.test.ts @@ -107,18 +107,6 @@ describe('dataObjectToApiInput', () => { 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', () => { @@ -134,29 +122,8 @@ describe('dataObjectToApiInput', () => { 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", - ); + const result = dataObjectToApiInput(input, mockNode, 0); + expect(result).toEqual(input); }); it('should include correct row number in error message', () => { @@ -206,9 +173,7 @@ describe('dataObjectToApiInput', () => { describe('buildGetManyFilter', () => { describe('isEmpty/isNotEmpty translation', () => { it('should translate isEmpty to eq with null value', () => { - const fieldEntries = [ - { keyName: 'name', condition: 'isEmpty' as const, keyValue: 'ignored' }, - ]; + const fieldEntries: FieldEntry[] = [{ keyName: 'name (string)', condition: 'isEmpty' }]; const result = buildGetManyFilter(fieldEntries, ALL_CONDITIONS, { name: 'string' }, mockNode); @@ -225,9 +190,7 @@ describe('buildGetManyFilter', () => { }); it('should translate isNotEmpty to neq with null value', () => { - const fieldEntries = [ - { keyName: 'email', condition: 'isNotEmpty' as const, keyValue: 'ignored' }, - ]; + const fieldEntries: FieldEntry[] = [{ keyName: 'email (string)', condition: 'isNotEmpty' }]; const result = buildGetManyFilter(fieldEntries, ANY_CONDITION, { email: 'string' }, mockNode); @@ -244,10 +207,10 @@ describe('buildGetManyFilter', () => { }); it('should handle mixed conditions including isEmpty/isNotEmpty', () => { - const fieldEntries = [ - { keyName: 'name', condition: 'eq' as const, keyValue: 'John' }, - { keyName: 'email', condition: 'isEmpty' as const, keyValue: 'ignored' }, - { keyName: 'phone', condition: 'isNotEmpty' as const, keyValue: 'ignored' }, + const fieldEntries: FieldEntry[] = [ + { keyName: 'name (string)', condition: 'eq', keyValue: 'John' }, + { keyName: 'email (string)', condition: 'isEmpty' }, + { keyName: 'phone (string)', condition: 'isNotEmpty' }, ]; const result = buildGetManyFilter( @@ -286,8 +249,8 @@ describe('buildGetManyFilter', () => { describe('isTrue/isFalse translation', () => { it('should translate isTrue to eq with true value', () => { - const fieldEntries = [ - { keyName: 'isActive', condition: 'isTrue' as const, keyValue: 'ignored' }, + const fieldEntries: FieldEntry[] = [ + { keyName: 'isActive (boolean)', condition: 'isTrue' as const }, ]; const result = buildGetManyFilter( @@ -310,9 +273,7 @@ describe('buildGetManyFilter', () => { }); it('should translate isFalse to eq with false value', () => { - const fieldEntries = [ - { keyName: 'email', condition: 'isFalse' as const, keyValue: 'ignored' }, - ]; + const fieldEntries: FieldEntry[] = [{ keyName: 'email (string)', condition: 'isFalse' }]; const result = buildGetManyFilter( fieldEntries, @@ -334,10 +295,10 @@ describe('buildGetManyFilter', () => { }); it('should handle mixed conditions including isTrue/isFalse', () => { - const fieldEntries = [ - { keyName: 'name', condition: 'eq' as const, keyValue: 'John' }, - { keyName: 'isActive', condition: 'isTrue' as const, keyValue: 'ignored' }, - { keyName: 'isDeleted', condition: 'isFalse' as const, keyValue: 'ignored' }, + const fieldEntries: FieldEntry[] = [ + { keyName: 'name (string)', condition: 'eq', keyValue: 'John' }, + { keyName: 'isActive (boolean)', condition: 'isTrue' }, + { keyName: 'isDeleted (boolean)', condition: 'isFalse' }, ]; const result = buildGetManyFilter( @@ -375,9 +336,9 @@ describe('buildGetManyFilter', () => { }); it('should handle other conditions', () => { - const fieldEntries = [ - { keyName: 'age', condition: 'gt' as const, keyValue: 18 }, - { keyName: 'name', condition: 'like' as const, keyValue: '%john%' }, + const fieldEntries: FieldEntry[] = [ + { keyName: 'age (number)', condition: 'gt', keyValue: 18 }, + { keyName: 'name (string)', condition: 'like', keyValue: '%john%' }, ]; const result = buildGetManyFilter( @@ -411,7 +372,7 @@ describe('buildGetManyFilter', () => { 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 }, + { keyName: 'createdAt (date)', condition: 'lte', keyValue: testDate }, ]; const result = buildGetManyFilter( @@ -436,7 +397,7 @@ describe('buildGetManyFilter', () => { 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 }, + { keyName: 'createdAt (date)', condition: 'lte', keyValue: dateString }, ]; const result = buildGetManyFilter( @@ -453,7 +414,7 @@ describe('buildGetManyFilter', () => { it('should throw an Error for invalid date strings', () => { const invalidDateString = 'invalid-date'; const fieldEntries: FieldEntry[] = [ - { keyName: 'createdAt', condition: 'lte', keyValue: invalidDateString }, + { keyName: 'createdAt (date)', condition: 'lte', keyValue: invalidDateString }, ]; expect(() =>