mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-30 19:35:36 +08:00
feat(Data Table Node): Add Order By feature (#23677)
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user