From 800075a69303514ea25c746245f8af68787e3333 Mon Sep 17 00:00:00 2001 From: Jason Schell <44125163+jason-schell@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:10:15 +0300 Subject: [PATCH] feat(Data Table Node): Add Order By feature (#23677) --- .../DataTable/actions/row/get.operation.ts | 67 +++- .../nodes/DataTable/common/selectMany.ts | 2 + .../DataTable/test/common/selectMany.test.ts | 139 ++++++++ .../DataTable/test/row/get.operation.test.ts | 315 ++++++++++++++++++ 4 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 packages/nodes-base/nodes/DataTable/test/row/get.operation.test.ts diff --git a/packages/nodes-base/nodes/DataTable/actions/row/get.operation.ts b/packages/nodes-base/nodes/DataTable/actions/row/get.operation.ts index 633bd84df2e..ff34e0b1bb4 100644 --- a/packages/nodes-base/nodes/DataTable/actions/row/get.operation.ts +++ b/packages/nodes-base/nodes/DataTable/actions/row/get.operation.ts @@ -45,6 +45,59 @@ export const description: INodeProperties[] = [ default: ROWS_LIMIT_DEFAULT, description: 'Max number of results to return', }, + { + displayName: 'Order By', + name: 'orderBy', + type: 'boolean', + displayOptions, + default: false, + description: 'Whether to sort the results by a column', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options + displayName: 'Order By Column', + name: 'orderByColumn', + type: 'options', + // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options + description: + 'Choose from the list, or specify using an expression', + typeOptions: { + loadOptionsDependsOn: ['dataTableId.value'], + loadOptionsMethod: 'getDataTableColumns', + }, + displayOptions: { + ...displayOptions, + show: { + ...displayOptions.show, + orderBy: [true], + }, + }, + default: 'createdAt', + }, + { + displayName: 'Order By Direction', + name: 'orderByDirection', + type: 'options', + options: [ + { + name: 'Ascending', + value: 'ASC', + }, + { + name: 'Descending', + value: 'DESC', + }, + ], + displayOptions: { + ...displayOptions, + show: { + ...displayOptions.show, + orderBy: [true], + }, + }, + default: 'DESC', + description: 'Sort direction for the column', + }, ]; export async function execute( @@ -53,5 +106,17 @@ export async function execute( ): Promise { const dataTableProxy = await getDataTableProxyExecute(this, index); - return await executeSelectMany(this, index, dataTableProxy); + // Extract sort parameters + let sortBy: [string, 'ASC' | 'DESC'] | undefined; + const orderBy = this.getNodeParameter('orderBy', index, false) as boolean; + + if (orderBy) { + const column = this.getNodeParameter('orderByColumn', index, '') as string; + const direction = this.getNodeParameter('orderByDirection', index, 'ASC') as 'ASC' | 'DESC'; + if (column) { + sortBy = [column, direction]; + } + } + + return await executeSelectMany(this, index, dataTableProxy, false, undefined, sortBy); } diff --git a/packages/nodes-base/nodes/DataTable/common/selectMany.ts b/packages/nodes-base/nodes/DataTable/common/selectMany.ts index 78a92a151ad..f88e89ec27d 100644 --- a/packages/nodes-base/nodes/DataTable/common/selectMany.ts +++ b/packages/nodes-base/nodes/DataTable/common/selectMany.ts @@ -180,6 +180,7 @@ export async function executeSelectMany( dataTableProxy: IDataTableProjectService, rejectEmpty = false, limit?: number, + sortBy?: [string, 'ASC' | 'DESC'], ): Promise> { const filter = await getSelectFilter(ctx, index); @@ -205,6 +206,7 @@ export async function executeSelectMany( skip, take: limit ? Math.min(take, limit - result.length) : take, filter, + sortBy, }); const wrapped = data.map((json) => ({ json: shouldConvertDates ? convertDatesToIsoStrings(json) : json, 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 74f2e0ddaaa..ce45e2a56fa 100644 --- a/packages/nodes-base/nodes/DataTable/test/common/selectMany.test.ts +++ b/packages/nodes-base/nodes/DataTable/test/common/selectMany.test.ts @@ -437,6 +437,145 @@ describe('selectMany utils', () => { ]); }); }); + + describe('sorting', () => { + it('should pass sortBy parameter to getManyRowsAndCount with ASC direction', async () => { + // ARRANGE + filters = []; + const sortBy: [string, 'ASC' | 'DESC'] = ['name', 'ASC']; + getManyRowsAndCount.mockReturnValue({ + data: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + count: 2, + }); + + // ACT + await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy, false, undefined, sortBy); + + // ASSERT + expect(getManyRowsAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + sortBy: ['name', 'ASC'], + }), + ); + }); + + it('should pass sortBy parameter to getManyRowsAndCount with DESC direction', async () => { + // ARRANGE + filters = []; + const sortBy: [string, 'ASC' | 'DESC'] = ['id', 'DESC']; + getManyRowsAndCount.mockReturnValue({ + data: [ + { id: 3, name: 'Charlie' }, + { id: 2, name: 'Bob' }, + { id: 1, name: 'Alice' }, + ], + count: 3, + }); + + // ACT + await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy, false, undefined, sortBy); + + // ASSERT + expect(getManyRowsAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + sortBy: ['id', 'DESC'], + }), + ); + }); + + it('should work with undefined sortBy', async () => { + // ARRANGE + filters = []; + getManyRowsAndCount.mockReturnValue({ + data: [{ id: 1, name: 'Alice' }], + count: 1, + }); + + // ACT + await executeSelectMany( + mockExecuteFunctions, + 0, + dataTableProxy, + false, + undefined, + undefined, + ); + + // ASSERT + expect(getManyRowsAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + sortBy: undefined, + }), + ); + }); + + it('should combine sortBy with filter conditions', async () => { + // ARRANGE + filters = [{ condition: 'eq', keyName: 'status', keyValue: 'active' }]; + const sortBy: [string, 'ASC' | 'DESC'] = ['name', 'ASC']; + getManyRowsAndCount.mockReturnValue({ + data: [ + { id: 1, name: 'Alice', status: 'active' }, + { id: 2, name: 'Bob', status: 'active' }, + ], + count: 2, + }); + + // ACT + await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy, false, undefined, sortBy); + + // ASSERT + expect(getManyRowsAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + sortBy: ['name', 'ASC'], + filter: expect.objectContaining({ + type: 'or', + filters: [ + { + columnName: 'status', + condition: 'eq', + value: 'active', + }, + ], + }), + }), + ); + }); + + it('should maintain sortBy across paginated requests', async () => { + // ARRANGE + filters = []; + const sortBy: [string, 'ASC' | 'DESC'] = ['id', 'ASC']; + getManyRowsAndCount.mockReturnValueOnce({ + data: Array.from({ length: 1000 }, (_, k) => ({ id: k })), + count: 1500, + }); + getManyRowsAndCount.mockReturnValueOnce({ + data: Array.from({ length: 500 }, (_, k) => ({ id: k + 1000 })), + count: 1500, + }); + + // ACT + await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy, false, undefined, sortBy); + + // ASSERT + expect(getManyRowsAndCount).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + sortBy: ['id', 'ASC'], + }), + ); + expect(getManyRowsAndCount).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + sortBy: ['id', 'ASC'], + }), + ); + }); + }); }); describe('getSelectFilter', () => { diff --git a/packages/nodes-base/nodes/DataTable/test/row/get.operation.test.ts b/packages/nodes-base/nodes/DataTable/test/row/get.operation.test.ts new file mode 100644 index 00000000000..1ee66a77035 --- /dev/null +++ b/packages/nodes-base/nodes/DataTable/test/row/get.operation.test.ts @@ -0,0 +1,315 @@ +import type { IDataTableProjectService, IExecuteFunctions, INode } from 'n8n-workflow'; + +import { ANY_CONDITION } from '../../common/constants'; +import { DATA_TABLE_ID_FIELD } from '../../common/fields'; +import * as getOperation from '../../actions/row/get.operation'; + +describe('DataTable Get Operation - Sort Feature', () => { + let mockExecuteFunctions: IExecuteFunctions; + let mockDataTableProxy: IDataTableProjectService; + const node = { id: 'test', typeVersion: 1.1 } as INode; + + beforeEach(() => { + const getManyRowsAndCount = jest.fn(); + const getColumns = jest.fn(); + + mockDataTableProxy = { + getManyRowsAndCount, + getColumns, + } as unknown as IDataTableProjectService; + + getColumns.mockResolvedValue([ + { name: 'id', type: 'number' }, + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + { name: 'status', type: 'string' }, + ]); + + mockExecuteFunctions = { + getNode: jest.fn().mockReturnValue(node), + getNodeParameter: jest.fn(), + helpers: { + getDataTableProxy: jest.fn().mockResolvedValue(mockDataTableProxy), + }, + } as unknown as IExecuteFunctions; + + jest.clearAllMocks(); + }); + + describe('Single Column Sort', () => { + it('should sort by column ascending', async () => { + // ARRANGE + (mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation((param) => { + if (param === DATA_TABLE_ID_FIELD) return { mode: 'id', value: 'table123' }; + if (param === 'orderBy') return true; + if (param === 'orderByColumn') return 'name'; + if (param === 'orderByDirection') return 'ASC'; + if (param === 'returnAll') return false; + if (param === 'limit') return 10; + if (param === 'filters.conditions') return []; + if (param === 'matchType') return ANY_CONDITION; + return undefined; + }); + + (mockDataTableProxy.getManyRowsAndCount as jest.Mock).mockResolvedValue({ + data: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + count: 2, + }); + + // ACT + await getOperation.execute.call(mockExecuteFunctions, 0); + + // ASSERT + expect(mockDataTableProxy.getManyRowsAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + sortBy: ['name', 'ASC'], + }), + ); + }); + + it('should sort by column descending', async () => { + // ARRANGE + (mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation((param) => { + if (param === DATA_TABLE_ID_FIELD) return { mode: 'id', value: 'table123' }; + if (param === 'orderBy') return true; + if (param === 'orderByColumn') return 'age'; + if (param === 'orderByDirection') return 'DESC'; + if (param === 'returnAll') return false; + if (param === 'limit') return 10; + if (param === 'filters.conditions') return []; + if (param === 'matchType') return ANY_CONDITION; + return undefined; + }); + + (mockDataTableProxy.getManyRowsAndCount as jest.Mock).mockResolvedValue({ + data: [ + { id: 2, age: 30 }, + { id: 1, age: 25 }, + ], + count: 2, + }); + + // ACT + await getOperation.execute.call(mockExecuteFunctions, 0); + + // ASSERT + expect(mockDataTableProxy.getManyRowsAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + sortBy: ['age', 'DESC'], + }), + ); + }); + + it('should sort by id column', async () => { + // ARRANGE + (mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation((param) => { + if (param === DATA_TABLE_ID_FIELD) return { mode: 'id', value: 'table123' }; + if (param === 'orderBy') return true; + if (param === 'orderByColumn') return 'id'; + if (param === 'orderByDirection') return 'ASC'; + if (param === 'returnAll') return true; + if (param === 'filters.conditions') return []; + if (param === 'matchType') return ANY_CONDITION; + return undefined; + }); + + (mockDataTableProxy.getManyRowsAndCount as jest.Mock).mockResolvedValue({ + data: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + ], + count: 3, + }); + + // ACT + await getOperation.execute.call(mockExecuteFunctions, 0); + + // ASSERT + expect(mockDataTableProxy.getManyRowsAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + sortBy: ['id', 'ASC'], + }), + ); + }); + }); + + describe('No Sort Rule', () => { + it('should work without sort rule (orderBy false)', async () => { + // ARRANGE + (mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation((param) => { + if (param === DATA_TABLE_ID_FIELD) return { mode: 'id', value: 'table123' }; + if (param === 'orderBy') return false; + if (param === 'returnAll') return false; + if (param === 'limit') return 10; + if (param === 'filters.conditions') return []; + if (param === 'matchType') return ANY_CONDITION; + return undefined; + }); + + (mockDataTableProxy.getManyRowsAndCount as jest.Mock).mockResolvedValue({ + data: [{ id: 1 }], + count: 1, + }); + + // ACT + await getOperation.execute.call(mockExecuteFunctions, 0); + + // ASSERT + expect(mockDataTableProxy.getManyRowsAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + sortBy: undefined, + }), + ); + }); + + it('should work with v1.0 (legacy version)', async () => { + // ARRANGE + const v10Node = { id: 'test', typeVersion: 1.0 } as INode; + (mockExecuteFunctions.getNode as jest.Mock).mockReturnValue(v10Node); + + (mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation((param) => { + if (param === DATA_TABLE_ID_FIELD) return { mode: 'id', value: 'table123' }; + if (param === 'orderBy') return false; + if (param === 'returnAll') return false; + if (param === 'limit') return 10; + if (param === 'filters.conditions') return []; + if (param === 'matchType') return ANY_CONDITION; + return undefined; + }); + + (mockDataTableProxy.getManyRowsAndCount as jest.Mock).mockResolvedValue({ + data: [{ id: 1 }], + count: 1, + }); + + // ACT + await getOperation.execute.call(mockExecuteFunctions, 0); + + // ASSERT + expect(mockDataTableProxy.getManyRowsAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + sortBy: undefined, + }), + ); + }); + }); + + describe('Sort with Filters', () => { + it('should combine sort and filters correctly', async () => { + // ARRANGE + (mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation((param) => { + if (param === DATA_TABLE_ID_FIELD) return { mode: 'id', value: 'table123' }; + if (param === 'orderBy') return true; + if (param === 'orderByColumn') return 'name'; + if (param === 'orderByDirection') return 'ASC'; + if (param === 'returnAll') return false; + if (param === 'limit') return 10; + if (param === 'filters.conditions') { + return [{ keyName: 'status', condition: 'eq', keyValue: 'active' }]; + } + if (param === 'matchType') return ANY_CONDITION; + return undefined; + }); + + (mockDataTableProxy.getManyRowsAndCount as jest.Mock).mockResolvedValue({ + data: [ + { id: 1, name: 'Alice', status: 'active' }, + { id: 2, name: 'Bob', status: 'active' }, + ], + count: 2, + }); + + // ACT + await getOperation.execute.call(mockExecuteFunctions, 0); + + // ASSERT + expect(mockDataTableProxy.getManyRowsAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + sortBy: ['name', 'ASC'], + filter: expect.objectContaining({ + type: 'or', + filters: [ + { + columnName: 'status', + condition: 'eq', + value: 'active', + }, + ], + }), + }), + ); + }); + }); + + describe('Sort with Pagination', () => { + it('should maintain sort order with returnAll=true', async () => { + // ARRANGE + (mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation((param) => { + if (param === DATA_TABLE_ID_FIELD) return { mode: 'id', value: 'table123' }; + if (param === 'orderBy') return true; + if (param === 'orderByColumn') return 'id'; + if (param === 'orderByDirection') return 'DESC'; + if (param === 'returnAll') return true; + if (param === 'filters.conditions') return []; + if (param === 'matchType') return ANY_CONDITION; + return undefined; + }); + + (mockDataTableProxy.getManyRowsAndCount as jest.Mock).mockResolvedValue({ + data: [{ id: 5 }, { id: 4 }, { id: 3 }], + count: 3, + }); + + // ACT + await getOperation.execute.call(mockExecuteFunctions, 0); + + // ASSERT + expect(mockDataTableProxy.getManyRowsAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + sortBy: ['id', 'DESC'], + }), + ); + }); + + it('should maintain sort order with limit', async () => { + // ARRANGE + (mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation((param) => { + if (param === DATA_TABLE_ID_FIELD) return { mode: 'id', value: 'table123' }; + if (param === 'orderBy') return true; + if (param === 'orderByColumn') return 'name'; + if (param === 'orderByDirection') return 'ASC'; + if (param === 'returnAll') return false; + if (param === 'limit') return 5; + if (param === 'filters.conditions') return []; + if (param === 'matchType') return ANY_CONDITION; + return undefined; + }); + + (mockDataTableProxy.getManyRowsAndCount as jest.Mock).mockResolvedValue({ + data: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + { id: 4, name: 'David' }, + { id: 5, name: 'Eve' }, + ], + count: 10, + }); + + // ACT + await getOperation.execute.call(mockExecuteFunctions, 0); + + // ASSERT + expect(mockDataTableProxy.getManyRowsAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + sortBy: ['name', 'ASC'], + take: 5, + }), + ); + }); + }); +});