feat(Data Table Node): Add Order By feature (#23677)

This commit is contained in:
Jason Schell
2026-01-13 12:10:15 +00:00
committed by GitHub
parent a19347a6bc
commit 800075a693
4 changed files with 522 additions and 1 deletions
@@ -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 <a href="https://docs.n8n.io/code/expressions/">expression</a>',
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<INodeExecutionData[]> {
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);
}
@@ -180,6 +180,7 @@ export async function executeSelectMany(
dataTableProxy: IDataTableProjectService,
rejectEmpty = false,
limit?: number,
sortBy?: [string, 'ASC' | 'DESC'],
): Promise<Array<{ json: DataTableRowReturn }>> {
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,
@@ -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', () => {
@@ -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,
}),
);
});
});
});