first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.dataTable",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"details": "Data table",
|
||||
"categories": ["Core Nodes", "Development"],
|
||||
"resources": {
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.datatable/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"alias": [
|
||||
"data",
|
||||
"table",
|
||||
"knowledge",
|
||||
"data table",
|
||||
"table",
|
||||
"sheet",
|
||||
"database",
|
||||
"data base",
|
||||
"mysql",
|
||||
"postgres",
|
||||
"postgresql",
|
||||
"airtable",
|
||||
"supabase",
|
||||
"noco",
|
||||
"notion"
|
||||
],
|
||||
"subcategories": {
|
||||
"Core Nodes": ["Helpers"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { IExecuteFunctions, INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { router } from './actions/router';
|
||||
import * as row from './actions/row/Row.resource';
|
||||
import * as table from './actions/table/Table.resource';
|
||||
import {
|
||||
getConditionsForColumn,
|
||||
getDataTableColumns,
|
||||
getDataTables,
|
||||
tableSearch,
|
||||
} from './common/methods';
|
||||
|
||||
export class DataTable implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Data table',
|
||||
name: 'dataTable',
|
||||
icon: 'fa:table',
|
||||
iconColor: 'orange-red',
|
||||
group: ['input', 'transform'],
|
||||
version: [1, 1.1],
|
||||
subtitle: '={{$parameter["action"]}}',
|
||||
description: 'Permanently save data across workflow executions in a table',
|
||||
defaults: {
|
||||
name: 'Data table',
|
||||
},
|
||||
usableAsTool: true,
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
hints: [
|
||||
{
|
||||
message: 'The selected data table has no columns.',
|
||||
displayCondition:
|
||||
'={{ $parameter.dataTableId !== "" && $parameter?.columns?.mappingMode === "defineBelow" && !$parameter?.columns?.schema?.length }}',
|
||||
whenToDisplay: 'beforeExecution',
|
||||
location: 'ndv',
|
||||
type: 'info',
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Row',
|
||||
value: 'row',
|
||||
},
|
||||
{
|
||||
name: 'Table',
|
||||
value: 'table',
|
||||
},
|
||||
],
|
||||
default: 'row',
|
||||
},
|
||||
...row.description,
|
||||
...table.description,
|
||||
],
|
||||
};
|
||||
|
||||
methods = {
|
||||
listSearch: {
|
||||
tableSearch,
|
||||
},
|
||||
loadOptions: {
|
||||
getDataTableColumns,
|
||||
getConditionsForColumn,
|
||||
},
|
||||
resourceMapping: {
|
||||
getDataTables,
|
||||
},
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions) {
|
||||
return await router.call(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"dataTableId": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"index": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"project": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"projectId": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deletedTableId": {
|
||||
"type": "string"
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"index": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"project": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"projectId": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
AllEntities,
|
||||
DataTableRowOperation,
|
||||
DataTableTableOperation,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import * as row from './row/Row.resource';
|
||||
import * as table from './table/Table.resource';
|
||||
import { DATA_TABLE_ID_FIELD } from '../common/fields';
|
||||
import { getDataTableProxyExecute } from '../common/utils';
|
||||
|
||||
type DataTableNodeType = AllEntities<{
|
||||
row: DataTableRowOperation;
|
||||
table: DataTableTableOperation;
|
||||
}>;
|
||||
|
||||
const BULK_OPERATIONS = ['insert'] as const;
|
||||
|
||||
function hasBulkExecute(operation: string): operation is (typeof BULK_OPERATIONS)[number] {
|
||||
return (BULK_OPERATIONS as readonly string[]).includes(operation);
|
||||
}
|
||||
|
||||
function hasComplexId(ctx: IExecuteFunctions) {
|
||||
const dataTableIdExpr = ctx.getNodeParameter(`${DATA_TABLE_ID_FIELD}.value`, 0, undefined, {
|
||||
rawExpressions: true,
|
||||
});
|
||||
|
||||
return typeof dataTableIdExpr === 'string' && dataTableIdExpr.includes('{');
|
||||
}
|
||||
|
||||
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
let operationResult: INodeExecutionData[] = [];
|
||||
let responseData: INodeExecutionData[] = [];
|
||||
|
||||
const items = this.getInputData();
|
||||
const resource = this.getNodeParameter('resource', 0);
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
|
||||
const dataTableNodeData = {
|
||||
resource,
|
||||
operation,
|
||||
} as DataTableNodeType;
|
||||
|
||||
if (dataTableNodeData.resource === 'table') {
|
||||
// Table operations
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
const tableOperation =
|
||||
dataTableNodeData.operation === 'delete'
|
||||
? table.deleteTable
|
||||
: table[dataTableNodeData.operation];
|
||||
responseData = await tableOperation.execute.call(this, i);
|
||||
const executionData = this.helpers.constructExecutionMetaData(responseData, {
|
||||
itemData: { item: i },
|
||||
});
|
||||
operationResult = operationResult.concat(executionData);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
const inputData = this.getInputData(i)[0].json;
|
||||
if (error instanceof NodeApiError || error instanceof NodeOperationError) {
|
||||
operationResult.push({ json: inputData, error });
|
||||
} else {
|
||||
operationResult.push({
|
||||
json: inputData,
|
||||
error: new NodeOperationError(this.getNode(), error as Error),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (hasBulkExecute(dataTableNodeData.operation) && !hasComplexId(this)) {
|
||||
// Row bulk operations
|
||||
try {
|
||||
const proxy = await getDataTableProxyExecute(this);
|
||||
|
||||
responseData = await row[dataTableNodeData.operation]['executeBulk'].call(this, proxy);
|
||||
|
||||
operationResult = responseData;
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
if (error instanceof NodeApiError || error instanceof NodeOperationError) {
|
||||
operationResult = this.getInputData().map((json) => ({ json, error }));
|
||||
} else {
|
||||
operationResult = this.getInputData().map((json) => ({ json }));
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Row operations
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
responseData = await row[dataTableNodeData.operation].execute.call(this, i);
|
||||
const executionData = this.helpers.constructExecutionMetaData(responseData, {
|
||||
itemData: { item: i },
|
||||
});
|
||||
|
||||
// pushing here risks stack overflows for very high numbers (~100k) of results on filter-based queries (update, get, etc.)
|
||||
operationResult = operationResult.concat(executionData);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
const inputData = this.getInputData(i)[0].json;
|
||||
if (error instanceof NodeApiError || error instanceof NodeOperationError) {
|
||||
operationResult.push({ json: inputData, error });
|
||||
} else {
|
||||
operationResult.push({ json: inputData });
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [operationResult];
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as deleteRows from './delete.operation';
|
||||
import * as rowExists from './rowExists.operation';
|
||||
import * as rowNotExists from './rowNotExists.operation';
|
||||
import * as get from './get.operation';
|
||||
import * as insert from './insert.operation';
|
||||
import * as update from './update.operation';
|
||||
import * as upsert from './upsert.operation';
|
||||
import { DATA_TABLE_RESOURCE_LOCATOR_BASE } from '../../common/fields';
|
||||
|
||||
export { insert, get, rowExists, rowNotExists, deleteRows, update, upsert };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['row'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Delete',
|
||||
value: deleteRows.FIELD,
|
||||
description: 'Delete row(s)',
|
||||
action: 'Delete row(s)',
|
||||
},
|
||||
{
|
||||
name: 'Get',
|
||||
value: get.FIELD,
|
||||
description: 'Get row(s)',
|
||||
action: 'Get row(s)',
|
||||
},
|
||||
{
|
||||
name: 'If Row Exists',
|
||||
value: rowExists.FIELD,
|
||||
description: 'Match input items that are in the data table',
|
||||
action: 'If row exists',
|
||||
},
|
||||
{
|
||||
name: 'If Row Does Not Exist',
|
||||
value: rowNotExists.FIELD,
|
||||
description: 'Match input items that are not in the data table',
|
||||
action: 'If row does not exist',
|
||||
},
|
||||
{
|
||||
name: 'Insert',
|
||||
value: insert.FIELD,
|
||||
description: 'Insert a new row',
|
||||
action: 'Insert row',
|
||||
},
|
||||
{
|
||||
name: 'Update',
|
||||
value: update.FIELD,
|
||||
description: 'Update row(s) matching certain fields',
|
||||
action: 'Update row(s)',
|
||||
},
|
||||
{
|
||||
name: 'Upsert',
|
||||
value: upsert.FIELD,
|
||||
description: 'Update row(s), or insert if there is no match',
|
||||
action: 'Upsert row(s)',
|
||||
},
|
||||
],
|
||||
default: 'insert',
|
||||
},
|
||||
{
|
||||
...DATA_TABLE_RESOURCE_LOCATOR_BASE,
|
||||
modes: [
|
||||
{
|
||||
...DATA_TABLE_RESOURCE_LOCATOR_BASE.modes[0],
|
||||
typeOptions: {
|
||||
...DATA_TABLE_RESOURCE_LOCATOR_BASE.modes[0].typeOptions,
|
||||
allowNewResource: {
|
||||
label: 'resourceLocator.dataTable.createNew',
|
||||
url: '/projects/{{$projectId}}/datatables/new',
|
||||
},
|
||||
},
|
||||
},
|
||||
...DATA_TABLE_RESOURCE_LOCATOR_BASE.modes.slice(1),
|
||||
],
|
||||
displayOptions: { show: { resource: ['row'] } },
|
||||
},
|
||||
...deleteRows.description,
|
||||
...insert.description,
|
||||
...get.description,
|
||||
...rowExists.description,
|
||||
...rowNotExists.description,
|
||||
...update.description,
|
||||
...upsert.description,
|
||||
];
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
NodeOperationError,
|
||||
type IDisplayOptions,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { DRY_RUN } from '../../common/fields';
|
||||
import { getSelectFields, getSelectFilter } from '../../common/selectMany';
|
||||
import { getDataTableProxyExecute, getDryRunParameter } from '../../common/utils';
|
||||
|
||||
// named `deleteRows` since `delete` is a reserved keyword
|
||||
export const FIELD: string = 'deleteRows';
|
||||
|
||||
const displayOptions: IDisplayOptions = {
|
||||
show: {
|
||||
resource: ['row'],
|
||||
operation: [FIELD],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
...getSelectFields(displayOptions),
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
placeholder: 'Add option',
|
||||
options: [DRY_RUN],
|
||||
displayOptions,
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const dataTableProxy = await getDataTableProxyExecute(this, index);
|
||||
const dryRun = getDryRunParameter(this, index);
|
||||
const filter = await getSelectFilter(this, index);
|
||||
|
||||
if (filter.filters.length === 0) {
|
||||
throw new NodeOperationError(this.getNode(), 'At least one condition is required');
|
||||
}
|
||||
|
||||
const result = await dataTableProxy.deleteRows({ filter, dryRun });
|
||||
|
||||
return result.map((json) => ({ json }));
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
IDisplayOptions,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ROWS_LIMIT_DEFAULT } from '../../common/constants';
|
||||
import { executeSelectMany, getSelectFields } from '../../common/selectMany';
|
||||
import { getDataTableProxyExecute } from '../../common/utils';
|
||||
|
||||
export const FIELD: string = 'get';
|
||||
|
||||
const displayOptions: IDisplayOptions = {
|
||||
show: {
|
||||
resource: ['row'],
|
||||
operation: [FIELD],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
...getSelectFields(displayOptions),
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
displayOptions,
|
||||
default: false,
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
...displayOptions,
|
||||
show: {
|
||||
...displayOptions.show,
|
||||
returnAll: [false],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
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(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const dataTableProxy = await getDataTableProxyExecute(this, index);
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
IDataTableProjectService,
|
||||
IDisplayOptions,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { getAddRow, makeAddRow } from '../../common/addRow';
|
||||
import { getDataTableProxyExecute } from '../../common/utils';
|
||||
|
||||
export const FIELD: string = 'insert';
|
||||
|
||||
const displayOptions: IDisplayOptions = {
|
||||
show: {
|
||||
resource: ['row'],
|
||||
operation: [FIELD],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
makeAddRow(FIELD, displayOptions),
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Optimize Bulk',
|
||||
name: 'optimizeBulk',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
noDataExpression: true, // bulk inserts don't support expressions so this is a bit paradoxical
|
||||
description: 'Whether to improve bulk insert performance 5x by not returning inserted data',
|
||||
},
|
||||
],
|
||||
displayOptions,
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const optimizeBulkEnabled = this.getNodeParameter('options.optimizeBulk', index, false);
|
||||
const dataTableProxy = await getDataTableProxyExecute(this, index);
|
||||
|
||||
const row = getAddRow(this, index);
|
||||
|
||||
if (optimizeBulkEnabled) {
|
||||
// This function is always called by index, so we inherently cannot operate in bulk
|
||||
this.addExecutionHints({
|
||||
message: 'Unable to optimize bulk insert due to expression in Data table ID ',
|
||||
location: 'outputPane',
|
||||
});
|
||||
const json = await dataTableProxy.insertRows([row], 'count');
|
||||
return [{ json }];
|
||||
} else {
|
||||
const insertedRows = await dataTableProxy.insertRows([row], 'all');
|
||||
return insertedRows.map((json, item) => ({ json, pairedItem: { item } }));
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeBulk(
|
||||
this: IExecuteFunctions,
|
||||
proxy: IDataTableProjectService,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const optimizeBulkEnabled = this.getNodeParameter('options.optimizeBulk', 0, false);
|
||||
const rows = this.getInputData().flatMap((_, i) => [getAddRow(this, i)]);
|
||||
|
||||
if (optimizeBulkEnabled) {
|
||||
const json = await proxy.insertRows(rows, 'count');
|
||||
return [{ json }];
|
||||
} else {
|
||||
const insertedRows = await proxy.insertRows(rows, 'all');
|
||||
return insertedRows.map((json, item) => ({ json, pairedItem: { item } }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
IDisplayOptions,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { executeSelectMany, getSelectFields } from '../../common/selectMany';
|
||||
import { getDataTableProxyExecute } from '../../common/utils';
|
||||
|
||||
export const FIELD: string = 'rowExists';
|
||||
|
||||
const displayOptions: IDisplayOptions = {
|
||||
show: {
|
||||
resource: ['row'],
|
||||
operation: [FIELD],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [...getSelectFields(displayOptions, true, true)];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const dataTableProxy = await getDataTableProxyExecute(this, index);
|
||||
const hits = await executeSelectMany(this, index, dataTableProxy, undefined, 1);
|
||||
return hits.length > 0 ? [this.getInputData()[index]] : [];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
IDisplayOptions,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { executeSelectMany, getSelectFields } from '../../common/selectMany';
|
||||
import { getDataTableProxyExecute } from '../../common/utils';
|
||||
|
||||
export const FIELD: string = 'rowNotExists';
|
||||
|
||||
const displayOptions: IDisplayOptions = {
|
||||
show: {
|
||||
resource: ['row'],
|
||||
operation: [FIELD],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [...getSelectFields(displayOptions, true, true)];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const dataTableProxy = await getDataTableProxyExecute(this, index);
|
||||
const hits = await executeSelectMany(this, index, dataTableProxy, undefined, 1);
|
||||
return hits.length === 0 ? [this.getInputData()[index]] : [];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
NodeOperationError,
|
||||
type IDisplayOptions,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { makeAddRow, getAddRow } from '../../common/addRow';
|
||||
import { DRY_RUN } from '../../common/fields';
|
||||
import { getSelectFields, getSelectFilter } from '../../common/selectMany';
|
||||
import { getDataTableProxyExecute, getDryRunParameter } from '../../common/utils';
|
||||
|
||||
export const FIELD: string = 'update';
|
||||
|
||||
const displayOptions: IDisplayOptions = {
|
||||
show: {
|
||||
resource: ['row'],
|
||||
operation: [FIELD],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
...getSelectFields(displayOptions),
|
||||
makeAddRow(FIELD, displayOptions),
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
placeholder: 'Add option',
|
||||
options: [DRY_RUN],
|
||||
displayOptions,
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const dataTableProxy = await getDataTableProxyExecute(this, index);
|
||||
const dryRun = getDryRunParameter(this, index);
|
||||
const row = getAddRow(this, index);
|
||||
const filter = await getSelectFilter(this, index);
|
||||
|
||||
if (filter.filters.length === 0) {
|
||||
throw new NodeOperationError(this.getNode(), 'At least one condition is required');
|
||||
}
|
||||
|
||||
const updatedRows = await dataTableProxy.updateRows({
|
||||
data: row,
|
||||
filter,
|
||||
dryRun,
|
||||
});
|
||||
|
||||
return updatedRows.map((json) => ({ json }));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
NodeOperationError,
|
||||
type IDisplayOptions,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { makeAddRow, getAddRow } from '../../common/addRow';
|
||||
import { DRY_RUN } from '../../common/fields';
|
||||
import { getSelectFields, getSelectFilter } from '../../common/selectMany';
|
||||
import { getDataTableProxyExecute, getDryRunParameter } from '../../common/utils';
|
||||
|
||||
export const FIELD: string = 'upsert';
|
||||
|
||||
const displayOptions: IDisplayOptions = {
|
||||
show: {
|
||||
resource: ['row'],
|
||||
operation: [FIELD],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
...getSelectFields(displayOptions, true),
|
||||
makeAddRow(FIELD, displayOptions),
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
placeholder: 'Add option',
|
||||
options: [DRY_RUN],
|
||||
displayOptions,
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const dataTableProxy = await getDataTableProxyExecute(this, index);
|
||||
const dryRun = getDryRunParameter(this, index);
|
||||
const row = getAddRow(this, index);
|
||||
const filter = await getSelectFilter(this, index);
|
||||
|
||||
if (filter.filters.length === 0) {
|
||||
throw new NodeOperationError(this.getNode(), 'At least one condition is required');
|
||||
}
|
||||
|
||||
const result = await dataTableProxy.upsertRow({
|
||||
data: row,
|
||||
filter,
|
||||
dryRun,
|
||||
});
|
||||
|
||||
return result.map((json) => ({ json }));
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as create from './create.operation';
|
||||
import * as deleteTable from './delete.operation';
|
||||
import * as list from './list.operation';
|
||||
import * as update from './update.operation';
|
||||
import { DATA_TABLE_RESOURCE_LOCATOR_BASE } from '../../common/fields';
|
||||
|
||||
export { create, deleteTable, list, update };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['table'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Create',
|
||||
value: create.FIELD,
|
||||
description: 'Create a new data table',
|
||||
action: 'Create a data table',
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
value: deleteTable.FIELD,
|
||||
description: 'Delete a data table',
|
||||
action: 'Delete a data table',
|
||||
},
|
||||
{
|
||||
name: 'List',
|
||||
value: list.FIELD,
|
||||
description: 'List all data tables',
|
||||
action: 'List data tables',
|
||||
},
|
||||
{
|
||||
name: 'Update',
|
||||
value: update.FIELD,
|
||||
description: 'Update a data table name',
|
||||
action: 'Update a data table',
|
||||
},
|
||||
],
|
||||
default: 'list',
|
||||
},
|
||||
{
|
||||
...DATA_TABLE_RESOURCE_LOCATOR_BASE,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['table'],
|
||||
operation: [deleteTable.FIELD, update.FIELD],
|
||||
},
|
||||
},
|
||||
},
|
||||
...create.description,
|
||||
...deleteTable.description,
|
||||
...list.description,
|
||||
...update.description,
|
||||
];
|
||||
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
CreateDataTableColumnOptions,
|
||||
IDisplayOptions,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { getDataTableAggregateProxy } from '../../common/utils';
|
||||
|
||||
export const FIELD = 'create';
|
||||
|
||||
const displayOptions: IDisplayOptions = {
|
||||
show: {
|
||||
resource: ['table'],
|
||||
operation: [FIELD],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'tableName',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: 'e.g. My Data Table',
|
||||
description: 'The name of the data table to create',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
default: {},
|
||||
placeholder: 'Add Column',
|
||||
description: 'The columns to create in the data table',
|
||||
displayOptions,
|
||||
options: [
|
||||
{
|
||||
name: 'column',
|
||||
displayName: 'Column',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'The name of the column',
|
||||
},
|
||||
{
|
||||
displayName: 'Type',
|
||||
name: 'type',
|
||||
type: 'options',
|
||||
default: 'string',
|
||||
options: [
|
||||
{ name: 'Boolean', value: 'boolean' },
|
||||
{ name: 'Date', value: 'date' },
|
||||
{ name: 'Number', value: 'number' },
|
||||
{ name: 'String', value: 'string' },
|
||||
],
|
||||
description: 'The type of the column',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Option',
|
||||
default: {},
|
||||
displayOptions,
|
||||
options: [
|
||||
{
|
||||
displayName: 'Reuse Existing Tables',
|
||||
name: 'createIfNotExists',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to return existing table if one exists with the same name without throwing an error',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const tableName = this.getNodeParameter('tableName', index) as string;
|
||||
const columnsData = this.getNodeParameter('columns.column', index, []) as Array<
|
||||
Pick<CreateDataTableColumnOptions, 'name' | 'type'>
|
||||
>;
|
||||
const options = this.getNodeParameter('options', index, {}) as {
|
||||
createIfNotExists?: boolean;
|
||||
};
|
||||
|
||||
const aggregateProxy = await getDataTableAggregateProxy(this);
|
||||
|
||||
// If "Create If Not Exists" is enabled, check if table already exists
|
||||
if (options.createIfNotExists) {
|
||||
const existingTables = await aggregateProxy.getManyAndCount({
|
||||
filter: { name: tableName },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
// If a table with exact name match exists, return it
|
||||
if (existingTables.data.length > 0 && existingTables.data[0].name === tableName) {
|
||||
return [{ json: existingTables.data[0] }];
|
||||
}
|
||||
}
|
||||
|
||||
const columns: CreateDataTableColumnOptions[] = columnsData.map((col, idx) => ({
|
||||
name: col.name,
|
||||
type: col.type,
|
||||
index: idx,
|
||||
}));
|
||||
|
||||
const result = await aggregateProxy.createDataTable({
|
||||
name: tableName,
|
||||
columns,
|
||||
});
|
||||
|
||||
return [{ json: result }];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
IDisplayOptions,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { DATA_TABLE_ID_FIELD } from '../../common/fields';
|
||||
import { getDataTableProxyExecute } from '../../common/utils';
|
||||
|
||||
export const FIELD = 'delete';
|
||||
|
||||
const displayOptions: IDisplayOptions = {
|
||||
show: {
|
||||
resource: ['table'],
|
||||
operation: [FIELD],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName:
|
||||
'This will permanently delete the data table and all its data. This action cannot be undone.',
|
||||
name: 'deleteWarning',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions,
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const dataTableId = this.getNodeParameter(DATA_TABLE_ID_FIELD, index, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const dataTableProxy = await getDataTableProxyExecute(this, index);
|
||||
|
||||
const success = await dataTableProxy.deleteDataTable();
|
||||
|
||||
return [{ json: { success, deletedTableId: dataTableId } }];
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type {
|
||||
IDisplayOptions,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
ListDataTableOptions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ROWS_LIMIT_DEFAULT } from '../../common/constants';
|
||||
import { getDataTableAggregateProxy } from '../../common/utils';
|
||||
|
||||
export const FIELD = 'list';
|
||||
|
||||
const displayOptions: IDisplayOptions = {
|
||||
show: {
|
||||
resource: ['table'],
|
||||
operation: [FIELD],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
default: ROWS_LIMIT_DEFAULT,
|
||||
description: 'Max number of results to return',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
...displayOptions.show,
|
||||
returnAll: [false],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Option',
|
||||
default: {},
|
||||
displayOptions,
|
||||
options: [
|
||||
{
|
||||
displayName: 'Filter by Name',
|
||||
name: 'filterName',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Filter data tables by name (case-insensitive)',
|
||||
},
|
||||
{
|
||||
displayName: 'Sort Field',
|
||||
name: 'sortField',
|
||||
type: 'options',
|
||||
default: 'name',
|
||||
options: [
|
||||
{ name: 'Created', value: 'createdAt' },
|
||||
{ name: 'Name', value: 'name' },
|
||||
{ name: 'Updated', value: 'updatedAt' },
|
||||
],
|
||||
description: 'Field to sort by',
|
||||
},
|
||||
{
|
||||
displayName: 'Sort Direction',
|
||||
name: 'sortDirection',
|
||||
type: 'options',
|
||||
default: 'asc',
|
||||
options: [
|
||||
{ name: 'Ascending', value: 'asc' },
|
||||
{ name: 'Descending', value: 'desc' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const returnAll = this.getNodeParameter('returnAll', index) as boolean;
|
||||
const limit = this.getNodeParameter('limit', index, ROWS_LIMIT_DEFAULT) as number;
|
||||
const options = this.getNodeParameter('options', index, {}) as {
|
||||
filterName?: string;
|
||||
sortField?: string;
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
const aggregateProxy = await getDataTableAggregateProxy(this);
|
||||
|
||||
const queryOptions: ListDataTableOptions = {};
|
||||
|
||||
if (options.sortField && options.sortDirection) {
|
||||
queryOptions.sortBy =
|
||||
`${options.sortField}:${options.sortDirection}` as ListDataTableOptions['sortBy'];
|
||||
}
|
||||
|
||||
if (options.filterName) {
|
||||
queryOptions.filter = { name: options.filterName.toLowerCase() };
|
||||
}
|
||||
|
||||
const results: INodeExecutionData[] = [];
|
||||
|
||||
if (returnAll) {
|
||||
let skip = 0;
|
||||
const take = 100;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const response = await aggregateProxy.getManyAndCount({
|
||||
...queryOptions,
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
for (const table of response.data) {
|
||||
results.push({ json: table });
|
||||
}
|
||||
|
||||
skip += take;
|
||||
hasMore = response.data.length === take && results.length < response.count;
|
||||
}
|
||||
} else {
|
||||
const response = await aggregateProxy.getManyAndCount({
|
||||
...queryOptions,
|
||||
skip: 0,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
for (const table of response.data) {
|
||||
results.push({ json: table });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
IDisplayOptions,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { getDataTableProxyExecute } from '../../common/utils';
|
||||
|
||||
export const FIELD = 'update';
|
||||
|
||||
const displayOptions: IDisplayOptions = {
|
||||
show: {
|
||||
resource: ['table'],
|
||||
operation: [FIELD],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'New Name',
|
||||
name: 'newName',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: 'e.g. Renamed Data Table',
|
||||
description: 'The new name for the data table',
|
||||
displayOptions,
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const newName = this.getNodeParameter('newName', index) as string;
|
||||
|
||||
const dataTableProxy = await getDataTableProxyExecute(this, index);
|
||||
|
||||
const success = await dataTableProxy.updateDataTable({ name: newName });
|
||||
|
||||
return [{ json: { success, name: newName } }];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
DATA_TABLE_SYSTEM_COLUMNS,
|
||||
type IDataObject,
|
||||
type IDisplayOptions,
|
||||
type IExecuteFunctions,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { DATA_TABLE_ID_FIELD } from './fields';
|
||||
import { dataObjectToApiInput } from './utils';
|
||||
|
||||
export function makeAddRow(operation: string, displayOptions: IDisplayOptions) {
|
||||
return {
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
noDataExpression: true,
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: [`${DATA_TABLE_ID_FIELD}.value`],
|
||||
resourceMapper: {
|
||||
valuesLabel: `Values to ${operation}`,
|
||||
resourceMapperMethod: 'getDataTables',
|
||||
mode: 'add',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: true,
|
||||
hideNoDataError: true,
|
||||
},
|
||||
},
|
||||
displayOptions,
|
||||
} satisfies INodeProperties;
|
||||
}
|
||||
|
||||
export function getAddRow(ctx: IExecuteFunctions, index: number) {
|
||||
const items = ctx.getInputData();
|
||||
const dataMode = ctx.getNodeParameter('columns.mappingMode', index) as string;
|
||||
|
||||
let data: IDataObject;
|
||||
|
||||
if (dataMode === 'autoMapInputData') {
|
||||
data = { ...items[index].json };
|
||||
// We automatically remove our system columns for better UX when feeding data table outputs
|
||||
// into another data table node
|
||||
for (const systemColumn of DATA_TABLE_SYSTEM_COLUMNS) {
|
||||
delete data[systemColumn];
|
||||
}
|
||||
} else {
|
||||
const fields = ctx.getNodeParameter('columns.value', index, {}) as IDataObject;
|
||||
|
||||
data = fields;
|
||||
}
|
||||
|
||||
return dataObjectToApiInput(data, ctx.getNode(), index);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { DataTableColumnJsType } from 'n8n-workflow';
|
||||
|
||||
export const ANY_CONDITION = 'anyCondition';
|
||||
export const ALL_CONDITIONS = 'allConditions';
|
||||
|
||||
export const ROWS_LIMIT_DEFAULT = 50;
|
||||
|
||||
export type FilterType = typeof ANY_CONDITION | typeof ALL_CONDITIONS;
|
||||
|
||||
export type FieldEntry =
|
||||
| {
|
||||
keyName: string;
|
||||
condition: 'isEmpty' | 'isNotEmpty' | 'isTrue' | 'isFalse';
|
||||
}
|
||||
| {
|
||||
keyName: string;
|
||||
condition?: 'eq' | 'neq' | 'like' | 'ilike' | 'gt' | 'gte' | 'lt' | 'lte';
|
||||
keyValue: DataTableColumnJsType;
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const DATA_TABLE_ID_FIELD = 'dataTableId';
|
||||
|
||||
export const DRY_RUN = {
|
||||
displayName: 'Dry Run',
|
||||
name: 'dryRun',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether the operation simulates and returns affected rows in their "before" and "after" states',
|
||||
} satisfies INodeProperties;
|
||||
|
||||
export const DATA_TABLE_RESOURCE_LOCATOR_BASE = {
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
displayName: 'Data table',
|
||||
name: DATA_TABLE_ID_FIELD,
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
builderHint: { message: "Default to mode: 'list' which is easier for users to set up" },
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'tableSearch',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. My Table',
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
} as const satisfies Omit<INodeProperties, 'displayOptions'>;
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP,
|
||||
type ILoadOptionsFunctions,
|
||||
type INodeListSearchResult,
|
||||
type INodePropertyOptions,
|
||||
type ResourceMapperField,
|
||||
type ResourceMapperFields,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { getDataTableAggregateProxy, getDataTableProxyLoadOptions } from './utils';
|
||||
|
||||
// @ADO-3904: Pagination here does not work until a filter is entered or removed, suspected bug in ResourceLocator
|
||||
export async function tableSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
filterString?: string,
|
||||
prevPaginationToken?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const proxy = await getDataTableAggregateProxy(this);
|
||||
|
||||
const skip = prevPaginationToken === undefined ? 0 : parseInt(prevPaginationToken, 10);
|
||||
const take = 100;
|
||||
const filter = filterString === undefined ? {} : { filter: { name: filterString.toLowerCase() } };
|
||||
const result = await proxy.getManyAndCount({
|
||||
skip,
|
||||
take,
|
||||
...filter,
|
||||
});
|
||||
|
||||
const results = result.data.map((row) => {
|
||||
return {
|
||||
name: row.name,
|
||||
value: row.id,
|
||||
url: `/projects/${proxy.getProjectId()}/datatables/${row.id}`,
|
||||
};
|
||||
});
|
||||
|
||||
const paginationToken = results.length === take ? `${skip + take}` : undefined;
|
||||
|
||||
return {
|
||||
results,
|
||||
paginationToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDataTableColumns(this: ILoadOptionsFunctions) {
|
||||
const returnData: Array<INodePropertyOptions & { type: string }> = Object.entries(
|
||||
DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP,
|
||||
).map(([name, type]) => ({
|
||||
name: `${name} (${type})`,
|
||||
value: name,
|
||||
type,
|
||||
}));
|
||||
|
||||
const proxy = await getDataTableProxyLoadOptions(this);
|
||||
if (!proxy) {
|
||||
return returnData;
|
||||
}
|
||||
|
||||
const columns = await proxy.getColumns();
|
||||
for (const column of columns) {
|
||||
returnData.push({
|
||||
name: `${column.name} (${column.type})`,
|
||||
value: column.name,
|
||||
type: column.type,
|
||||
});
|
||||
}
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export async function getConditionsForColumn(this: ILoadOptionsFunctions) {
|
||||
const proxy = await getDataTableProxyLoadOptions(this);
|
||||
if (!proxy) {
|
||||
return [];
|
||||
}
|
||||
const keyName = this.getCurrentNodeParameter('&keyName') as string;
|
||||
|
||||
const nullConditions: INodePropertyOptions[] = [
|
||||
{ name: 'Is Empty', value: 'isEmpty' },
|
||||
{ name: 'Is Not Empty', value: 'isNotEmpty' },
|
||||
];
|
||||
|
||||
const equalsConditions: INodePropertyOptions[] = [
|
||||
{ name: 'Equals', value: 'eq' },
|
||||
{ name: 'Not Equals', value: 'neq' },
|
||||
];
|
||||
|
||||
const booleanConditions: INodePropertyOptions[] = [
|
||||
{ name: 'Is True', value: 'isTrue' },
|
||||
{ name: 'Is False', value: 'isFalse' },
|
||||
];
|
||||
|
||||
const comparableConditions: INodePropertyOptions[] = [
|
||||
{ name: 'Greater Than', value: 'gt' },
|
||||
{ name: 'Greater Than or Equal', value: 'gte' },
|
||||
{ name: 'Less Than', value: 'lt' },
|
||||
{ name: 'Less Than or Equal', value: 'lte' },
|
||||
];
|
||||
|
||||
const stringConditions: INodePropertyOptions[] = [
|
||||
{ name: 'Contains (Case-Sensitive)', value: 'like' },
|
||||
{ name: 'Contains (Case-Insensitive)', value: 'ilike' },
|
||||
];
|
||||
|
||||
const allConditions = [
|
||||
...nullConditions,
|
||||
...equalsConditions,
|
||||
...booleanConditions,
|
||||
...comparableConditions,
|
||||
...stringConditions,
|
||||
];
|
||||
|
||||
// If no column is selected yet, return all conditions
|
||||
if (!keyName) {
|
||||
return allConditions;
|
||||
}
|
||||
|
||||
// Get column type to determine available conditions
|
||||
const type =
|
||||
DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP[keyName] ??
|
||||
(await proxy.getColumns()).find((col) => col.name === keyName)?.type;
|
||||
|
||||
if (!type) {
|
||||
return [...equalsConditions, ...nullConditions];
|
||||
}
|
||||
|
||||
const conditions: INodePropertyOptions[] = [];
|
||||
|
||||
if (type === 'boolean') {
|
||||
conditions.push.apply(conditions, booleanConditions);
|
||||
}
|
||||
|
||||
// String columns get LIKE operators
|
||||
if (type === 'string') {
|
||||
conditions.push.apply(conditions, equalsConditions);
|
||||
conditions.push.apply(conditions, stringConditions);
|
||||
conditions.push.apply(conditions, comparableConditions);
|
||||
}
|
||||
|
||||
if (['number', 'date'].includes(type)) {
|
||||
conditions.push.apply(conditions, equalsConditions);
|
||||
conditions.push.apply(conditions, comparableConditions);
|
||||
}
|
||||
|
||||
conditions.push.apply(conditions, nullConditions);
|
||||
|
||||
return conditions;
|
||||
}
|
||||
|
||||
export async function getDataTables(this: ILoadOptionsFunctions): Promise<ResourceMapperFields> {
|
||||
const proxy = await getDataTableProxyLoadOptions(this);
|
||||
if (!proxy) {
|
||||
return { fields: [] };
|
||||
}
|
||||
const result = await proxy.getColumns();
|
||||
|
||||
const fields: ResourceMapperField[] = [];
|
||||
|
||||
for (const field of result) {
|
||||
const type = field.type === 'date' ? 'dateTime' : field.type;
|
||||
|
||||
fields.push({
|
||||
id: field.name,
|
||||
displayName: field.name,
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type,
|
||||
readOnly: false,
|
||||
removed: false,
|
||||
});
|
||||
}
|
||||
|
||||
return { fields };
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP, NodeOperationError } from 'n8n-workflow';
|
||||
import type {
|
||||
DataTableFilter,
|
||||
DataTableRowReturn,
|
||||
IDataTableProjectService,
|
||||
IDisplayOptions,
|
||||
IExecuteFunctions,
|
||||
INodeProperties,
|
||||
DataTableColumnType,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ALL_CONDITIONS, ANY_CONDITION, ROWS_LIMIT_DEFAULT, type FilterType } from './constants';
|
||||
import { DATA_TABLE_ID_FIELD } from './fields';
|
||||
import { buildGetManyFilter, isFieldArray, isMatchType, getDataTableProxyExecute } from './utils';
|
||||
|
||||
/**
|
||||
* Recursively converts Date objects to ISO strings in an object
|
||||
* This ensures that all output data is JSON-compatible
|
||||
*/
|
||||
function convertDatesToIsoStrings<T>(obj: T): T {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (obj instanceof Date) {
|
||||
return obj.toISOString() as T;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(convertDatesToIsoStrings) as T;
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const converted: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
converted[key] = convertDatesToIsoStrings(value);
|
||||
}
|
||||
return converted as T;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function getSelectFields(
|
||||
displayOptions: IDisplayOptions,
|
||||
requireCondition = false,
|
||||
skipOperator = false,
|
||||
): INodeProperties[] {
|
||||
return [
|
||||
{
|
||||
displayName: 'Must Match',
|
||||
name: 'matchType',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Any Condition',
|
||||
value: ANY_CONDITION,
|
||||
},
|
||||
{
|
||||
name: 'All Conditions',
|
||||
value: ALL_CONDITIONS,
|
||||
},
|
||||
] satisfies Array<{ value: FilterType; name: string }>,
|
||||
displayOptions,
|
||||
default: ANY_CONDITION,
|
||||
},
|
||||
{
|
||||
displayName: 'Conditions',
|
||||
name: 'filters',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
minRequiredFields: requireCondition ? 1 : 0,
|
||||
},
|
||||
displayOptions,
|
||||
default: {},
|
||||
placeholder: 'Add Condition',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Conditions',
|
||||
name: 'conditions',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'keyName',
|
||||
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: [`${DATA_TABLE_ID_FIELD}.value`],
|
||||
loadOptionsMethod: 'getDataTableColumns',
|
||||
},
|
||||
default: 'id',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Condition',
|
||||
name: 'condition',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-missing-from-dynamic-options
|
||||
type: 'options',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['&keyName'],
|
||||
loadOptionsMethod: 'getConditionsForColumn',
|
||||
},
|
||||
default: 'eq',
|
||||
displayOptions: skipOperator
|
||||
? {
|
||||
show: { '@version': [{ _cnd: { lt: 0 } }] },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'keyValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
condition: ['isEmpty', 'isNotEmpty', 'isTrue', 'isFalse'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
description: 'Filter to decide which rows get',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function getSelectFilter(
|
||||
ctx: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<DataTableFilter> {
|
||||
const fields = ctx.getNodeParameter('filters.conditions', index, []);
|
||||
const matchType = ctx.getNodeParameter('matchType', index, ANY_CONDITION);
|
||||
const node = ctx.getNode();
|
||||
|
||||
if (!isMatchType(matchType)) {
|
||||
throw new NodeOperationError(node, 'unexpected match type');
|
||||
}
|
||||
if (!isFieldArray(fields)) {
|
||||
throw new NodeOperationError(node, 'unexpected fields input');
|
||||
}
|
||||
|
||||
// Validate filter conditions against current table schema
|
||||
let allColumnsWithTypes: Record<string, DataTableColumnType> = DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP;
|
||||
|
||||
if (fields.length > 0) {
|
||||
const dataTableProxy = await getDataTableProxyExecute(ctx, index);
|
||||
const availableColumns = await dataTableProxy.getColumns();
|
||||
|
||||
// Add system columns with their types
|
||||
allColumnsWithTypes = {
|
||||
...DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP,
|
||||
...Object.fromEntries(availableColumns.map((col) => [col.name, col.type])),
|
||||
};
|
||||
|
||||
const invalidConditions = fields.filter((field) => !allColumnsWithTypes[field.keyName]);
|
||||
|
||||
if (invalidConditions.length > 0) {
|
||||
const invalidColumnNames = invalidConditions.map((c) => c.keyName).join(', ');
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`Filter validation failed: Column(s) "${invalidColumnNames}" do not exist in the selected table. ` +
|
||||
'This often happens when switching between tables with different schemas. ' +
|
||||
'Please update your filter conditions.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return buildGetManyFilter(fields, matchType, allColumnsWithTypes, node);
|
||||
}
|
||||
|
||||
export async function executeSelectMany(
|
||||
ctx: IExecuteFunctions,
|
||||
index: number,
|
||||
dataTableProxy: IDataTableProjectService,
|
||||
rejectEmpty = false,
|
||||
limit?: number,
|
||||
sortBy?: [string, 'ASC' | 'DESC'],
|
||||
): Promise<Array<{ json: DataTableRowReturn }>> {
|
||||
const filter = await getSelectFilter(ctx, index);
|
||||
|
||||
if (rejectEmpty && filter.filters.length === 0) {
|
||||
throw new NodeOperationError(ctx.getNode(), 'At least one condition is required');
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 1000;
|
||||
const result: Array<{ json: DataTableRowReturn }> = [];
|
||||
|
||||
const returnAll = ctx.getNodeParameter('returnAll', index, false);
|
||||
limit = limit ?? (!returnAll ? ctx.getNodeParameter('limit', index, ROWS_LIMIT_DEFAULT) : 0);
|
||||
|
||||
const nodeVersion = ctx.getNode().typeVersion;
|
||||
const shouldConvertDates = nodeVersion >= 1.1;
|
||||
|
||||
let expectedTotal: number | undefined;
|
||||
let skip = 0;
|
||||
let take = PAGE_SIZE;
|
||||
|
||||
while (true) {
|
||||
const { data, count } = await dataTableProxy.getManyRowsAndCount({
|
||||
skip,
|
||||
take: limit ? Math.min(take, limit - result.length) : take,
|
||||
filter,
|
||||
sortBy,
|
||||
});
|
||||
const wrapped = data.map((json) => ({
|
||||
json: shouldConvertDates ? convertDatesToIsoStrings(json) : json,
|
||||
}));
|
||||
|
||||
// Fast path: everything fits in a single page
|
||||
if (skip === 0 && count === data.length) {
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
// Ensure the total doesn't change mid-pagination
|
||||
if (expectedTotal !== undefined && count !== expectedTotal) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
'synchronization error: result count changed during pagination',
|
||||
);
|
||||
}
|
||||
expectedTotal = count;
|
||||
|
||||
result.push.apply(result, wrapped);
|
||||
|
||||
// Stop if we've hit the limit
|
||||
if (limit && result.length >= limit) break;
|
||||
|
||||
// Stop if we've collected everything
|
||||
if (result.length >= count) break;
|
||||
|
||||
skip = result.length;
|
||||
take = Math.min(PAGE_SIZE, count - result.length);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import type {
|
||||
IDataObject,
|
||||
INode,
|
||||
DataTableFilter,
|
||||
IDataTableProjectAggregateService,
|
||||
IDataTableProjectService,
|
||||
IExecuteFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
DataTableColumnJsType,
|
||||
DataTableColumnType,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type { FieldEntry, FilterType } from './constants';
|
||||
import { ALL_CONDITIONS, ANY_CONDITION } from './constants';
|
||||
import { DATA_TABLE_ID_FIELD, DRY_RUN } 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'
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to resolve data table ID from resourceLocator
|
||||
export async function resolveDataTableId(
|
||||
ctx: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
resourceLocator: { mode: 'list' | 'id' | 'name'; value: string },
|
||||
): Promise<string> {
|
||||
if (resourceLocator.mode === 'name') {
|
||||
// Look up table by name
|
||||
const aggregateProxy = await getDataTableAggregateProxy(ctx);
|
||||
const response = await aggregateProxy.getManyAndCount({
|
||||
filter: { name: resourceLocator.value.toLowerCase() },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (response.data.length === 0) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Data table with name "${resourceLocator.value}" not found`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.data[0].id;
|
||||
} else {
|
||||
// For 'list' and 'id' modes, use the value from the resource locator
|
||||
return resourceLocator.value;
|
||||
}
|
||||
}
|
||||
|
||||
// We need two functions here since the available getNodeParameter
|
||||
// overloads vary with the index
|
||||
export async function getDataTableProxyExecute(
|
||||
ctx: IExecuteFunctions,
|
||||
index: number = 0,
|
||||
): Promise<IDataTableProjectService> {
|
||||
if (ctx.helpers.getDataTableProxy === undefined)
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
'Attempted to use Data table node but the module is disabled',
|
||||
);
|
||||
|
||||
const resourceLocator = ctx.getNodeParameter(DATA_TABLE_ID_FIELD, index) as {
|
||||
mode: 'list' | 'id' | 'name';
|
||||
value: string;
|
||||
};
|
||||
|
||||
const dataTableId = await resolveDataTableId(ctx, resourceLocator);
|
||||
|
||||
return await ctx.helpers.getDataTableProxy(dataTableId);
|
||||
}
|
||||
|
||||
export async function getDataTableProxyLoadOptions(
|
||||
ctx: ILoadOptionsFunctions,
|
||||
): Promise<IDataTableProjectService | undefined> {
|
||||
if (ctx.helpers.getDataTableProxy === undefined)
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
'Attempted to use Data table node but the module is disabled',
|
||||
);
|
||||
|
||||
const resourceLocator = ctx.getNodeParameter(DATA_TABLE_ID_FIELD) as {
|
||||
mode: 'list' | 'id' | 'name';
|
||||
value: string;
|
||||
};
|
||||
|
||||
if (!resourceLocator || !resourceLocator.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataTableId = await resolveDataTableId(ctx, resourceLocator);
|
||||
|
||||
return await ctx.helpers.getDataTableProxy(dataTableId);
|
||||
}
|
||||
|
||||
export async function getDataTableAggregateProxy(
|
||||
ctx: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
): Promise<IDataTableProjectAggregateService> {
|
||||
if (ctx.helpers.getDataTableAggregateProxy === undefined)
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
'Attempted to use Data table node but the module is disabled',
|
||||
);
|
||||
|
||||
return await ctx.helpers.getDataTableAggregateProxy();
|
||||
}
|
||||
|
||||
export function isFieldEntry(obj: unknown): obj is FieldEntry {
|
||||
if (obj === null || typeof obj !== 'object') return false;
|
||||
return 'keyName' in obj; // keyValue and condition are optional
|
||||
}
|
||||
|
||||
export function isMatchType(obj: unknown): obj is FilterType {
|
||||
return typeof obj === 'string' && (obj === ANY_CONDITION || obj === ALL_CONDITIONS);
|
||||
}
|
||||
|
||||
export function buildGetManyFilter(
|
||||
fieldEntries: FieldEntry[],
|
||||
matchType: FilterType,
|
||||
columnTypeMap: Record<string, DataTableColumnType>,
|
||||
node: INode,
|
||||
): DataTableFilter {
|
||||
const filters = fieldEntries.map((x) => {
|
||||
switch (x.condition) {
|
||||
case 'isEmpty':
|
||||
return {
|
||||
columnName: x.keyName,
|
||||
condition: 'eq' as const,
|
||||
value: null,
|
||||
};
|
||||
case 'isNotEmpty':
|
||||
return {
|
||||
columnName: x.keyName,
|
||||
condition: 'neq' as const,
|
||||
value: null,
|
||||
};
|
||||
case 'isTrue':
|
||||
return {
|
||||
columnName: x.keyName,
|
||||
condition: 'eq' as const,
|
||||
value: true,
|
||||
};
|
||||
case 'isFalse':
|
||||
return {
|
||||
columnName: x.keyName,
|
||||
condition: 'eq' as const,
|
||||
value: false,
|
||||
};
|
||||
default: {
|
||||
let value = x.keyValue;
|
||||
const columnType = columnTypeMap[x.keyName];
|
||||
|
||||
// Convert ISO date strings to Date objects for date columns
|
||||
if (columnType === 'date' && typeof value === 'string') {
|
||||
const parsed = new Date(value);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`Invalid date string '${value}' for column '${x.keyName}'`,
|
||||
);
|
||||
}
|
||||
value = parsed;
|
||||
}
|
||||
return {
|
||||
columnName: x.keyName,
|
||||
condition: x.condition ?? 'eq',
|
||||
value,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
return { type: matchType === ALL_CONDITIONS ? 'and' : 'or', filters };
|
||||
}
|
||||
|
||||
export function isFieldArray(value: unknown): value is FieldEntry[] {
|
||||
return (
|
||||
value !== null && typeof value === 'object' && Array.isArray(value) && value.every(isFieldEntry)
|
||||
);
|
||||
}
|
||||
|
||||
export function dataObjectToApiInput(
|
||||
data: IDataObject,
|
||||
node: INode,
|
||||
row: number,
|
||||
): Record<string, DataTableColumnJsType> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(data).map(([k, v]): [string, DataTableColumnJsType] => {
|
||||
if (v === undefined || v === null) return [k, null];
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`unexpected array input '${JSON.stringify(v)}' in row ${row}`,
|
||||
);
|
||||
}
|
||||
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
|
||||
return [k, v];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getDryRunParameter(ctx: IExecuteFunctions, index: number): boolean {
|
||||
const dryRun = ctx.getNodeParameter(`options.${DRY_RUN.name}`, index, false);
|
||||
|
||||
if (typeof dryRun !== 'boolean') {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`unexpected input ${JSON.stringify(dryRun)} for boolean dryRun`,
|
||||
);
|
||||
}
|
||||
|
||||
return dryRun;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { resolveDataTableId } from '../../common/utils';
|
||||
|
||||
const mockNode: INode = {
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
describe('resolveDataTableId', () => {
|
||||
describe('list mode', () => {
|
||||
it('should return the value directly when mode is list', async () => {
|
||||
const ctx = mock<IExecuteFunctions>();
|
||||
ctx.getNode.mockReturnValue(mockNode);
|
||||
|
||||
const resourceLocator = {
|
||||
mode: 'list' as const,
|
||||
value: 'table-id-123',
|
||||
};
|
||||
|
||||
const result = await resolveDataTableId(ctx, resourceLocator);
|
||||
|
||||
expect(result).toBe('table-id-123');
|
||||
});
|
||||
|
||||
it('should handle UUIDs in list mode', async () => {
|
||||
const ctx = mock<IExecuteFunctions>();
|
||||
ctx.getNode.mockReturnValue(mockNode);
|
||||
|
||||
const resourceLocator = {
|
||||
mode: 'list' as const,
|
||||
value: '550e8400-e29b-41d4-a716-446655440000',
|
||||
};
|
||||
|
||||
const result = await resolveDataTableId(ctx, resourceLocator);
|
||||
|
||||
expect(result).toBe('550e8400-e29b-41d4-a716-446655440000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('id mode', () => {
|
||||
it('should return the value directly when mode is id', async () => {
|
||||
const ctx = mock<IExecuteFunctions>();
|
||||
ctx.getNode.mockReturnValue(mockNode);
|
||||
|
||||
const resourceLocator = {
|
||||
mode: 'id' as const,
|
||||
value: 'custom-table-id',
|
||||
};
|
||||
|
||||
const result = await resolveDataTableId(ctx, resourceLocator);
|
||||
|
||||
expect(result).toBe('custom-table-id');
|
||||
});
|
||||
|
||||
it('should handle numeric IDs in id mode', async () => {
|
||||
const ctx = mock<IExecuteFunctions>();
|
||||
ctx.getNode.mockReturnValue(mockNode);
|
||||
|
||||
const resourceLocator = {
|
||||
mode: 'id' as const,
|
||||
value: '12345',
|
||||
};
|
||||
|
||||
const result = await resolveDataTableId(ctx, resourceLocator);
|
||||
|
||||
expect(result).toBe('12345');
|
||||
});
|
||||
});
|
||||
|
||||
describe('name mode', () => {
|
||||
it('should look up table by name and return its ID', async () => {
|
||||
const ctx = mock<IExecuteFunctions>();
|
||||
ctx.getNode.mockReturnValue(mockNode);
|
||||
|
||||
const mockAggregateProxy = {
|
||||
getManyAndCount: jest.fn().mockResolvedValue({
|
||||
data: [{ id: 'resolved-table-id', name: 'my table' }],
|
||||
count: 1,
|
||||
}),
|
||||
};
|
||||
|
||||
ctx.helpers = {
|
||||
getDataTableAggregateProxy: jest.fn().mockResolvedValue(mockAggregateProxy),
|
||||
} as any;
|
||||
|
||||
const resourceLocator = {
|
||||
mode: 'name' as const,
|
||||
value: 'My Table',
|
||||
};
|
||||
|
||||
const result = await resolveDataTableId(ctx, resourceLocator);
|
||||
|
||||
expect(result).toBe('resolved-table-id');
|
||||
expect(mockAggregateProxy.getManyAndCount).toHaveBeenCalledWith({
|
||||
filter: { name: 'my table' },
|
||||
take: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert table name to lowercase for lookup', async () => {
|
||||
const ctx = mock<IExecuteFunctions>();
|
||||
ctx.getNode.mockReturnValue(mockNode);
|
||||
|
||||
const mockAggregateProxy = {
|
||||
getManyAndCount: jest.fn().mockResolvedValue({
|
||||
data: [{ id: 'table-id', name: 'customers' }],
|
||||
count: 1,
|
||||
}),
|
||||
};
|
||||
|
||||
ctx.helpers = {
|
||||
getDataTableAggregateProxy: jest.fn().mockResolvedValue(mockAggregateProxy),
|
||||
} as any;
|
||||
|
||||
const resourceLocator = {
|
||||
mode: 'name' as const,
|
||||
value: 'CUSTOMERS',
|
||||
};
|
||||
|
||||
await resolveDataTableId(ctx, resourceLocator);
|
||||
|
||||
expect(mockAggregateProxy.getManyAndCount).toHaveBeenCalledWith({
|
||||
filter: { name: 'customers' },
|
||||
take: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when table name is not found', async () => {
|
||||
const ctx = mock<IExecuteFunctions>();
|
||||
ctx.getNode.mockReturnValue(mockNode);
|
||||
|
||||
const mockAggregateProxy = {
|
||||
getManyAndCount: jest.fn().mockResolvedValue({
|
||||
data: [],
|
||||
count: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
ctx.helpers = {
|
||||
getDataTableAggregateProxy: jest.fn().mockResolvedValue(mockAggregateProxy),
|
||||
} as any;
|
||||
|
||||
const resourceLocator = {
|
||||
mode: 'name' as const,
|
||||
value: 'NonExistentTable',
|
||||
};
|
||||
|
||||
await expect(resolveDataTableId(ctx, resourceLocator)).rejects.toThrow(NodeOperationError);
|
||||
await expect(resolveDataTableId(ctx, resourceLocator)).rejects.toThrow(
|
||||
'Data table with name "NonExistentTable" not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle special characters in table names', async () => {
|
||||
const ctx = mock<IExecuteFunctions>();
|
||||
ctx.getNode.mockReturnValue(mockNode);
|
||||
|
||||
const mockAggregateProxy = {
|
||||
getManyAndCount: jest.fn().mockResolvedValue({
|
||||
data: [{ id: 'table-id', name: 'users & customers' }],
|
||||
count: 1,
|
||||
}),
|
||||
};
|
||||
|
||||
ctx.helpers = {
|
||||
getDataTableAggregateProxy: jest.fn().mockResolvedValue(mockAggregateProxy),
|
||||
} as any;
|
||||
|
||||
const resourceLocator = {
|
||||
mode: 'name' as const,
|
||||
value: 'Users & Customers',
|
||||
};
|
||||
|
||||
const result = await resolveDataTableId(ctx, resourceLocator);
|
||||
|
||||
expect(result).toBe('table-id');
|
||||
expect(mockAggregateProxy.getManyAndCount).toHaveBeenCalledWith({
|
||||
filter: { name: 'users & customers' },
|
||||
take: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,660 @@
|
||||
import {
|
||||
type INode,
|
||||
NodeOperationError,
|
||||
type IDataTableProjectService,
|
||||
type IExecuteFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { FieldEntry } from '../../common/constants';
|
||||
import { ANY_CONDITION, ALL_CONDITIONS } from '../../common/constants';
|
||||
import { DATA_TABLE_ID_FIELD } from '../../common/fields';
|
||||
import { executeSelectMany, getSelectFilter } from '../../common/selectMany';
|
||||
|
||||
describe('selectMany utils', () => {
|
||||
let mockExecuteFunctions: IExecuteFunctions;
|
||||
const getManyRowsAndCount = jest.fn();
|
||||
const dataTableProxy = jest.mocked<IDataTableProjectService>({
|
||||
getManyRowsAndCount,
|
||||
} as unknown as IDataTableProjectService);
|
||||
const dataTableId = 2345;
|
||||
let filters: FieldEntry[];
|
||||
const node = { id: 1 } as unknown as INode;
|
||||
|
||||
beforeEach(() => {
|
||||
filters = [
|
||||
{
|
||||
condition: 'eq',
|
||||
keyName: 'id',
|
||||
keyValue: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const mockDataTableProxy = {
|
||||
getColumns: jest.fn().mockResolvedValue([
|
||||
{ name: 'name', type: 'string' },
|
||||
{ name: 'age', type: 'number' },
|
||||
{ name: 'status', type: 'string' },
|
||||
]),
|
||||
};
|
||||
|
||||
mockExecuteFunctions = {
|
||||
getNode: jest.fn().mockReturnValue(node),
|
||||
getNodeParameter: jest.fn().mockImplementation((field) => {
|
||||
switch (field) {
|
||||
case DATA_TABLE_ID_FIELD:
|
||||
return dataTableId;
|
||||
case 'filters.conditions':
|
||||
return filters;
|
||||
case 'matchType':
|
||||
return ANY_CONDITION;
|
||||
}
|
||||
}),
|
||||
helpers: {
|
||||
getDataTableProxy: jest.fn().mockResolvedValue(mockDataTableProxy),
|
||||
},
|
||||
} as unknown as IExecuteFunctions;
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('executeSelectMany', () => {
|
||||
it('should get a few rows', async () => {
|
||||
// ARRANGE
|
||||
getManyRowsAndCount.mockReturnValue({ data: [{ id: 1 }], count: 1 });
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toEqual([{ json: { id: 1 } }]);
|
||||
});
|
||||
|
||||
it('should get a paginated amount of rows', async () => {
|
||||
// ARRANGE
|
||||
getManyRowsAndCount.mockReturnValueOnce({
|
||||
data: Array.from({ length: 1000 }, (_, k) => ({ id: k })),
|
||||
count: 2345,
|
||||
});
|
||||
getManyRowsAndCount.mockReturnValueOnce({
|
||||
data: Array.from({ length: 1000 }, (_, k) => ({ id: k + 1000 })),
|
||||
count: 2345,
|
||||
});
|
||||
|
||||
getManyRowsAndCount.mockReturnValueOnce({
|
||||
data: Array.from({ length: 345 }, (_, k) => ({ id: k + 2000 })),
|
||||
count: 2345,
|
||||
});
|
||||
|
||||
filters = [];
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
expect(result.length).toBe(2345);
|
||||
expect(result[0]).toEqual({ json: { id: 0 } });
|
||||
expect(result[2344]).toEqual({ json: { id: 2344 } });
|
||||
});
|
||||
|
||||
it('should pass null through correctly', async () => {
|
||||
// ARRANGE
|
||||
getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, colA: null }], count: 1 });
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toEqual([{ json: { id: 1, colA: null } }]);
|
||||
});
|
||||
|
||||
it('should panic if pagination gets out of sync', async () => {
|
||||
// ARRANGE
|
||||
getManyRowsAndCount.mockReturnValueOnce({
|
||||
data: Array.from({ length: 1000 }, (_, k) => ({ id: k })),
|
||||
count: 2345,
|
||||
});
|
||||
getManyRowsAndCount.mockReturnValueOnce({
|
||||
data: Array.from({ length: 1000 }, (_, k) => ({ id: k + 1000 })),
|
||||
count: 2344,
|
||||
});
|
||||
|
||||
filters = [];
|
||||
|
||||
// ACT ASSERT
|
||||
await expect(executeSelectMany(mockExecuteFunctions, 0, dataTableProxy)).rejects.toEqual(
|
||||
new NodeOperationError(
|
||||
node,
|
||||
'synchronization error: result count changed during pagination',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('filter conditions', () => {
|
||||
it('should handle "eq" condition', async () => {
|
||||
// ARRANGE
|
||||
filters = [{ condition: 'eq', keyName: 'name', keyValue: 'John' }];
|
||||
getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, name: 'John' }], count: 1 });
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toEqual([{ json: { id: 1, name: 'John' } }]);
|
||||
});
|
||||
|
||||
it('should handle "neq" condition', async () => {
|
||||
// ARRANGE
|
||||
filters = [{ condition: 'neq', keyName: 'name', keyValue: 'John' }];
|
||||
getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, name: 'Jane' }], count: 1 });
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toEqual([{ json: { id: 1, name: 'Jane' } }]);
|
||||
});
|
||||
|
||||
it('should handle "gt" condition with numbers', async () => {
|
||||
// ARRANGE
|
||||
filters = [{ condition: 'gt', keyName: 'age', keyValue: 25 }];
|
||||
getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, age: 30 }], count: 1 });
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toEqual([{ json: { id: 1, age: 30 } }]);
|
||||
});
|
||||
|
||||
it('should handle "gte" condition with numbers', async () => {
|
||||
// ARRANGE
|
||||
filters = [{ condition: 'gte', keyName: 'age', keyValue: 25 }];
|
||||
getManyRowsAndCount.mockReturnValue({
|
||||
data: [
|
||||
{ id: 1, age: 25 },
|
||||
{ id: 2, age: 30 },
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toEqual([{ json: { id: 1, age: 25 } }, { json: { id: 2, age: 30 } }]);
|
||||
});
|
||||
|
||||
it('should handle "lt" condition with numbers', async () => {
|
||||
// ARRANGE
|
||||
filters = [{ condition: 'lt', keyName: 'age', keyValue: 30 }];
|
||||
getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, age: 25 }], count: 1 });
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toEqual([{ json: { id: 1, age: 25 } }]);
|
||||
});
|
||||
|
||||
it('should handle "lte" condition with numbers', async () => {
|
||||
// ARRANGE
|
||||
filters = [{ condition: 'lte', keyName: 'age', keyValue: 30 }];
|
||||
getManyRowsAndCount.mockReturnValue({
|
||||
data: [
|
||||
{ id: 1, age: 25 },
|
||||
{ id: 2, age: 30 },
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toEqual([{ json: { id: 1, age: 25 } }, { json: { id: 2, age: 30 } }]);
|
||||
});
|
||||
|
||||
it('should handle "like" condition with pattern matching', async () => {
|
||||
// ARRANGE
|
||||
filters = [{ condition: 'like', keyName: 'name', keyValue: '%Mar%' }];
|
||||
getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, name: 'Anne-Marie' }], count: 1 });
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toEqual([{ json: { id: 1, name: 'Anne-Marie' } }]);
|
||||
});
|
||||
|
||||
it('should handle "ilike" condition with case-insensitive pattern matching', async () => {
|
||||
// ARRANGE
|
||||
filters = [{ condition: 'ilike', keyName: 'name', keyValue: '%mar%' }];
|
||||
getManyRowsAndCount.mockReturnValue({ data: [{ id: 1, name: 'Anne-Marie' }], count: 1 });
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toEqual([{ json: { id: 1, name: 'Anne-Marie' } }]);
|
||||
});
|
||||
|
||||
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 },
|
||||
];
|
||||
getManyRowsAndCount.mockReturnValue({
|
||||
data: [{ id: 1, status: 'active', age: 25 }],
|
||||
count: 1,
|
||||
});
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toEqual([{ json: { id: 1, status: 'active', age: 25 } }]);
|
||||
});
|
||||
|
||||
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 },
|
||||
];
|
||||
mockExecuteFunctions.getNodeParameter = jest.fn().mockImplementation((field) => {
|
||||
switch (field) {
|
||||
case DATA_TABLE_ID_FIELD:
|
||||
return dataTableId;
|
||||
case 'filters.conditions':
|
||||
return filters;
|
||||
case 'matchType':
|
||||
return ALL_CONDITIONS;
|
||||
}
|
||||
});
|
||||
getManyRowsAndCount.mockReturnValue({
|
||||
data: [{ id: 1, status: 'active', age: 25 }],
|
||||
count: 1,
|
||||
});
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toEqual([{ json: { id: 1, status: 'active', age: 25 } }]);
|
||||
});
|
||||
|
||||
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 },
|
||||
];
|
||||
mockExecuteFunctions.getNodeParameter = jest.fn().mockImplementation((field) => {
|
||||
switch (field) {
|
||||
case DATA_TABLE_ID_FIELD:
|
||||
return dataTableId;
|
||||
case 'filters.conditions':
|
||||
return filters;
|
||||
case 'matchType':
|
||||
return ALL_CONDITIONS;
|
||||
}
|
||||
});
|
||||
getManyRowsAndCount.mockReturnValue({
|
||||
data: [],
|
||||
count: 0,
|
||||
});
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
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 },
|
||||
];
|
||||
mockExecuteFunctions.getNodeParameter = jest.fn().mockImplementation((field) => {
|
||||
switch (field) {
|
||||
case DATA_TABLE_ID_FIELD:
|
||||
return dataTableId;
|
||||
case 'filters.conditions':
|
||||
return filters;
|
||||
case 'matchType':
|
||||
return ANY_CONDITION;
|
||||
}
|
||||
});
|
||||
getManyRowsAndCount.mockReturnValue({
|
||||
data: [{ id: 1, status: 'active', age: 25 }],
|
||||
count: 1,
|
||||
});
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toEqual([{ json: { id: 1, status: 'active', age: 25 } }]);
|
||||
});
|
||||
|
||||
it('should convert Date objects to ISO strings in output (v1.1+)', async () => {
|
||||
// ARRANGE
|
||||
const testDate = new Date('2025-12-11T10:30:59.000Z');
|
||||
const testUpdatedDate = new Date('2025-12-12T11:16:53.385Z');
|
||||
filters = [];
|
||||
mockExecuteFunctions.getNodeParameter = jest.fn().mockImplementation((field) => {
|
||||
switch (field) {
|
||||
case DATA_TABLE_ID_FIELD:
|
||||
return dataTableId;
|
||||
case 'filters.conditions':
|
||||
return filters;
|
||||
case 'matchType':
|
||||
return ANY_CONDITION;
|
||||
case 'returnAll':
|
||||
return true;
|
||||
}
|
||||
});
|
||||
mockExecuteFunctions.getNode = jest.fn().mockReturnValue({ ...node, typeVersion: 1.1 });
|
||||
getManyRowsAndCount.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
completedDate: testDate,
|
||||
createdAt: testDate,
|
||||
updatedAt: testUpdatedDate,
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
});
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
// Dates should be converted to ISO strings, not Date objects
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
id: 1,
|
||||
completedDate: '2025-12-11T10:30:59.000Z',
|
||||
createdAt: '2025-12-11T10:30:59.000Z',
|
||||
updatedAt: '2025-12-12T11:16:53.385Z',
|
||||
status: 'active',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should keep Date objects in output (v1.0 - legacy behavior)', async () => {
|
||||
// ARRANGE
|
||||
const testDate = new Date('2025-12-11T10:30:59.000Z');
|
||||
const testUpdatedDate = new Date('2025-12-12T11:16:53.385Z');
|
||||
filters = [];
|
||||
mockExecuteFunctions.getNodeParameter = jest.fn().mockImplementation((field) => {
|
||||
switch (field) {
|
||||
case DATA_TABLE_ID_FIELD:
|
||||
return dataTableId;
|
||||
case 'filters.conditions':
|
||||
return filters;
|
||||
case 'matchType':
|
||||
return ANY_CONDITION;
|
||||
case 'returnAll':
|
||||
return true;
|
||||
}
|
||||
});
|
||||
mockExecuteFunctions.getNode = jest.fn().mockReturnValue({ ...node, typeVersion: 1 });
|
||||
getManyRowsAndCount.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
completedDate: testDate,
|
||||
createdAt: testDate,
|
||||
updatedAt: testUpdatedDate,
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
});
|
||||
|
||||
// ACT
|
||||
const result = await executeSelectMany(mockExecuteFunctions, 0, dataTableProxy);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
id: 1,
|
||||
completedDate: testDate,
|
||||
createdAt: testDate,
|
||||
updatedAt: testUpdatedDate,
|
||||
status: 'active',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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
|
||||
];
|
||||
|
||||
// ACT & ASSERT
|
||||
await expect(getSelectFilter(mockExecuteFunctions, 0)).rejects.toEqual(
|
||||
new NodeOperationError(
|
||||
node,
|
||||
'Filter validation failed: Column(s) "invalid_column" do not exist in the selected table. ' +
|
||||
'This often happens when switching between tables with different schemas. ' +
|
||||
'Please update your filter conditions.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
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
|
||||
];
|
||||
|
||||
// ACT
|
||||
const result = await getSelectFilter(mockExecuteFunctions, 0);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toBeDefined();
|
||||
expect(result.filters).toHaveLength(2);
|
||||
});
|
||||
|
||||
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
|
||||
];
|
||||
|
||||
// ACT
|
||||
const result = await getSelectFilter(mockExecuteFunctions, 0);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toBeDefined();
|
||||
expect(result.filters).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should pass validation when no filters are provided', async () => {
|
||||
// ARRANGE
|
||||
filters = [];
|
||||
|
||||
// ACT
|
||||
const result = await getSelectFilter(mockExecuteFunctions, 0);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toBeDefined();
|
||||
expect(result.filters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should report multiple invalid columns in error message', async () => {
|
||||
// ARRANGE
|
||||
filters = [
|
||||
{ condition: 'eq', keyName: 'invalid1', keyValue: 'test1' },
|
||||
{ condition: 'eq', keyName: 'invalid2', keyValue: 'test2' },
|
||||
];
|
||||
|
||||
// ACT & ASSERT
|
||||
await expect(getSelectFilter(mockExecuteFunctions, 0)).rejects.toEqual(
|
||||
new NodeOperationError(
|
||||
node,
|
||||
'Filter validation failed: Column(s) "invalid1, invalid2" do not exist in the selected table. ' +
|
||||
'This often happens when switching between tables with different schemas. ' +
|
||||
'Please update your filter conditions.',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,464 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { ANY_CONDITION, ALL_CONDITIONS, type FieldEntry } from '../../common/constants';
|
||||
import { dataObjectToApiInput, buildGetManyFilter } from '../../common/utils';
|
||||
|
||||
const mockNode: INode = {
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'test',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
describe('dataObjectToApiInput', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 result = buildGetManyFilter(fieldEntries, ALL_CONDITIONS, { name: 'string' }, mockNode);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'and',
|
||||
filters: [
|
||||
{
|
||||
columnName: 'name',
|
||||
condition: 'eq',
|
||||
value: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should translate isNotEmpty to neq with null value', () => {
|
||||
const fieldEntries = [
|
||||
{ keyName: 'email', condition: 'isNotEmpty' as const, keyValue: 'ignored' },
|
||||
];
|
||||
|
||||
const result = buildGetManyFilter(fieldEntries, ANY_CONDITION, { email: 'string' }, mockNode);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'or',
|
||||
filters: [
|
||||
{
|
||||
columnName: 'email',
|
||||
condition: 'neq',
|
||||
value: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
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 result = buildGetManyFilter(
|
||||
fieldEntries,
|
||||
ALL_CONDITIONS,
|
||||
{
|
||||
name: 'string',
|
||||
email: 'string',
|
||||
phone: 'string',
|
||||
},
|
||||
mockNode,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'and',
|
||||
filters: [
|
||||
{
|
||||
columnName: 'name',
|
||||
condition: 'eq',
|
||||
value: 'John',
|
||||
},
|
||||
{
|
||||
columnName: 'email',
|
||||
condition: 'eq',
|
||||
value: null,
|
||||
},
|
||||
{
|
||||
columnName: 'phone',
|
||||
condition: 'neq',
|
||||
value: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTrue/isFalse translation', () => {
|
||||
it('should translate isTrue to eq with true value', () => {
|
||||
const fieldEntries = [
|
||||
{ keyName: 'isActive', condition: 'isTrue' as const, keyValue: 'ignored' },
|
||||
];
|
||||
|
||||
const result = buildGetManyFilter(
|
||||
fieldEntries,
|
||||
ALL_CONDITIONS,
|
||||
{ isActive: 'boolean' },
|
||||
mockNode,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'and',
|
||||
filters: [
|
||||
{
|
||||
columnName: 'isActive',
|
||||
condition: 'eq',
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should translate isFalse to eq with false value', () => {
|
||||
const fieldEntries = [
|
||||
{ keyName: 'email', condition: 'isFalse' as const, keyValue: 'ignored' },
|
||||
];
|
||||
|
||||
const result = buildGetManyFilter(
|
||||
fieldEntries,
|
||||
ANY_CONDITION,
|
||||
{ email: 'boolean' },
|
||||
mockNode,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'or',
|
||||
filters: [
|
||||
{
|
||||
columnName: 'email',
|
||||
condition: 'eq',
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
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 result = buildGetManyFilter(
|
||||
fieldEntries,
|
||||
ALL_CONDITIONS,
|
||||
{
|
||||
name: 'string',
|
||||
isActive: 'boolean',
|
||||
isDeleted: 'boolean',
|
||||
},
|
||||
mockNode,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'and',
|
||||
filters: [
|
||||
{
|
||||
columnName: 'name',
|
||||
condition: 'eq',
|
||||
value: 'John',
|
||||
},
|
||||
{
|
||||
columnName: 'isActive',
|
||||
condition: 'eq',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
columnName: 'isDeleted',
|
||||
condition: 'eq',
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle other conditions', () => {
|
||||
const fieldEntries = [
|
||||
{ keyName: 'age', condition: 'gt' as const, keyValue: 18 },
|
||||
{ keyName: 'name', condition: 'like' as const, keyValue: '%john%' },
|
||||
];
|
||||
|
||||
const result = buildGetManyFilter(
|
||||
fieldEntries,
|
||||
ANY_CONDITION,
|
||||
{
|
||||
age: 'number',
|
||||
name: 'string',
|
||||
},
|
||||
mockNode,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'or',
|
||||
filters: [
|
||||
{
|
||||
columnName: 'age',
|
||||
condition: 'gt',
|
||||
value: 18,
|
||||
},
|
||||
{
|
||||
columnName: 'name',
|
||||
condition: 'like',
|
||||
value: '%john%',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe('date handling in filters', () => {
|
||||
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 },
|
||||
];
|
||||
|
||||
const result = buildGetManyFilter(
|
||||
fieldEntries,
|
||||
ALL_CONDITIONS,
|
||||
{ createdAt: 'date' },
|
||||
mockNode,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'and',
|
||||
filters: [
|
||||
{
|
||||
columnName: 'createdAt',
|
||||
condition: 'lte',
|
||||
value: testDate,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
const result = buildGetManyFilter(
|
||||
fieldEntries,
|
||||
ALL_CONDITIONS,
|
||||
{ createdAt: 'date' },
|
||||
mockNode,
|
||||
);
|
||||
|
||||
expect(result.filters[0].value).toBeInstanceOf(Date);
|
||||
expect((result.filters[0].value as Date).toISOString()).toBe(dateString);
|
||||
});
|
||||
|
||||
it('should throw an Error for invalid date strings', () => {
|
||||
const invalidDateString = 'invalid-date';
|
||||
const fieldEntries: FieldEntry[] = [
|
||||
{ keyName: 'createdAt', condition: 'lte', keyValue: invalidDateString },
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
buildGetManyFilter(fieldEntries, ALL_CONDITIONS, { createdAt: 'date' }, mockNode),
|
||||
).toThrowError(`Invalid date string '${invalidDateString}' for column 'createdAt'`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
IDataTableProjectAggregateService,
|
||||
IDataTableProjectService,
|
||||
INode,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import * as createOperation from '../../actions/table/create.operation';
|
||||
import * as deleteOperation from '../../actions/table/delete.operation';
|
||||
import * as listOperation from '../../actions/table/list.operation';
|
||||
import * as updateOperation from '../../actions/table/update.operation';
|
||||
|
||||
const mockNode: INode = {
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
describe('Table Operations', () => {
|
||||
describe('Create Operation', () => {
|
||||
it('should create a new data table with columns', async () => {
|
||||
const mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
const mockAggregateProxy = mock<IDataTableProjectAggregateService>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'tableName') return 'My Test Table';
|
||||
if (paramName === 'columns.column') {
|
||||
return [
|
||||
{ name: 'name', type: 'string' },
|
||||
{ name: 'age', type: 'number' },
|
||||
{ name: 'isActive', type: 'boolean' },
|
||||
];
|
||||
}
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
getDataTableAggregateProxy: jest.fn().mockResolvedValue(mockAggregateProxy),
|
||||
} as any;
|
||||
|
||||
const mockResult = {
|
||||
id: 'table-123',
|
||||
name: 'My Test Table',
|
||||
columns: [
|
||||
{ name: 'name', type: 'string', index: 0 },
|
||||
{ name: 'age', type: 'number', index: 1 },
|
||||
{ name: 'isActive', type: 'boolean', index: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
mockAggregateProxy.createDataTable.mockResolvedValue(mockResult as any);
|
||||
|
||||
const result = await createOperation.execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockAggregateProxy.createDataTable).toHaveBeenCalledWith({
|
||||
name: 'My Test Table',
|
||||
columns: [
|
||||
{ name: 'name', type: 'string', index: 0 },
|
||||
{ name: 'age', type: 'number', index: 1 },
|
||||
{ name: 'isActive', type: 'boolean', index: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ json: mockResult }]);
|
||||
});
|
||||
|
||||
it('should create a table with empty columns array', async () => {
|
||||
const mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
const mockAggregateProxy = mock<IDataTableProjectAggregateService>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'tableName') return 'Empty Table';
|
||||
if (paramName === 'columns.column') return [];
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
getDataTableAggregateProxy: jest.fn().mockResolvedValue(mockAggregateProxy),
|
||||
} as any;
|
||||
|
||||
const mockResult = {
|
||||
id: 'table-456',
|
||||
name: 'Empty Table',
|
||||
columns: [],
|
||||
};
|
||||
|
||||
mockAggregateProxy.createDataTable.mockResolvedValue(mockResult as any);
|
||||
|
||||
const result = await createOperation.execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockAggregateProxy.createDataTable).toHaveBeenCalledWith({
|
||||
name: 'Empty Table',
|
||||
columns: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ json: mockResult }]);
|
||||
});
|
||||
|
||||
it('should return existing table when createIfNotExists is enabled and table exists', async () => {
|
||||
const mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
const mockAggregateProxy = mock<IDataTableProjectAggregateService>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'tableName') return 'Existing Table';
|
||||
if (paramName === 'columns.column') {
|
||||
return [{ name: 'col1', type: 'string' }];
|
||||
}
|
||||
if (paramName === 'options') return { createIfNotExists: true };
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
getDataTableAggregateProxy: jest.fn().mockResolvedValue(mockAggregateProxy),
|
||||
} as any;
|
||||
|
||||
const existingTable = {
|
||||
id: 'existing-123',
|
||||
name: 'Existing Table',
|
||||
columns: [{ name: 'oldCol', type: 'string' }],
|
||||
};
|
||||
|
||||
mockAggregateProxy.getManyAndCount.mockResolvedValue({
|
||||
data: [existingTable],
|
||||
count: 1,
|
||||
} as any);
|
||||
|
||||
const result = await createOperation.execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockAggregateProxy.getManyAndCount).toHaveBeenCalledWith({
|
||||
filter: { name: 'Existing Table' },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
expect(mockAggregateProxy.createDataTable).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([{ json: existingTable }]);
|
||||
});
|
||||
|
||||
it('should create new table when createIfNotExists is enabled but table does not exist', async () => {
|
||||
const mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
const mockAggregateProxy = mock<IDataTableProjectAggregateService>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'tableName') return 'New Table';
|
||||
if (paramName === 'columns.column') {
|
||||
return [{ name: 'col1', type: 'string' }];
|
||||
}
|
||||
if (paramName === 'options') return { createIfNotExists: true };
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
getDataTableAggregateProxy: jest.fn().mockResolvedValue(mockAggregateProxy),
|
||||
} as any;
|
||||
|
||||
mockAggregateProxy.getManyAndCount.mockResolvedValue({
|
||||
data: [],
|
||||
count: 0,
|
||||
} as any);
|
||||
|
||||
const newTable = {
|
||||
id: 'new-123',
|
||||
name: 'New Table',
|
||||
columns: [{ name: 'col1', type: 'string', index: 0 }],
|
||||
};
|
||||
|
||||
mockAggregateProxy.createDataTable.mockResolvedValue(newTable as any);
|
||||
|
||||
const result = await createOperation.execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockAggregateProxy.getManyAndCount).toHaveBeenCalledWith({
|
||||
filter: { name: 'New Table' },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
expect(mockAggregateProxy.createDataTable).toHaveBeenCalledWith({
|
||||
name: 'New Table',
|
||||
columns: [{ name: 'col1', type: 'string', index: 0 }],
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ json: newTable }]);
|
||||
});
|
||||
|
||||
it('should support all column types', async () => {
|
||||
const mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
const mockAggregateProxy = mock<IDataTableProjectAggregateService>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'tableName') return 'Multi Type Table';
|
||||
if (paramName === 'columns.column') {
|
||||
return [
|
||||
{ name: 'stringCol', type: 'string' },
|
||||
{ name: 'numberCol', type: 'number' },
|
||||
{ name: 'booleanCol', type: 'boolean' },
|
||||
{ name: 'dateCol', type: 'date' },
|
||||
];
|
||||
}
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
getDataTableAggregateProxy: jest.fn().mockResolvedValue(mockAggregateProxy),
|
||||
} as any;
|
||||
|
||||
const mockResult = {
|
||||
id: 'table-789',
|
||||
name: 'Multi Type Table',
|
||||
columns: [
|
||||
{ name: 'stringCol', type: 'string', index: 0 },
|
||||
{ name: 'numberCol', type: 'number', index: 1 },
|
||||
{ name: 'booleanCol', type: 'boolean', index: 2 },
|
||||
{ name: 'dateCol', type: 'date', index: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
mockAggregateProxy.createDataTable.mockResolvedValue(mockResult as any);
|
||||
|
||||
const result = await createOperation.execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockAggregateProxy.createDataTable).toHaveBeenCalledWith({
|
||||
name: 'Multi Type Table',
|
||||
columns: [
|
||||
{ name: 'stringCol', type: 'string', index: 0 },
|
||||
{ name: 'numberCol', type: 'number', index: 1 },
|
||||
{ name: 'booleanCol', type: 'boolean', index: 2 },
|
||||
{ name: 'dateCol', type: 'date', index: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ json: mockResult }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Delete Operation', () => {
|
||||
it('should delete a data table successfully', async () => {
|
||||
const mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
const mockDataTableProxy = mock<IDataTableProjectService>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'dataTableId') return 'table-123';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
getDataTableProxy: jest.fn().mockResolvedValue(mockDataTableProxy),
|
||||
} as any;
|
||||
|
||||
mockDataTableProxy.deleteDataTable.mockResolvedValue(true);
|
||||
|
||||
const result = await deleteOperation.execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockDataTableProxy.deleteDataTable).toHaveBeenCalled();
|
||||
expect(result).toEqual([{ json: { success: true, deletedTableId: 'table-123' } }]);
|
||||
});
|
||||
|
||||
it('should return success false when deletion fails', async () => {
|
||||
const mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
const mockDataTableProxy = mock<IDataTableProjectService>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'dataTableId') return 'table-456';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
getDataTableProxy: jest.fn().mockResolvedValue(mockDataTableProxy),
|
||||
} as any;
|
||||
|
||||
mockDataTableProxy.deleteDataTable.mockResolvedValue(false);
|
||||
|
||||
const result = await deleteOperation.execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockDataTableProxy.deleteDataTable).toHaveBeenCalled();
|
||||
expect(result).toEqual([{ json: { success: false, deletedTableId: 'table-456' } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('List Operation', () => {
|
||||
it('should list all data tables without filters', async () => {
|
||||
const mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
const mockAggregateProxy = mock<IDataTableProjectAggregateService>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'returnAll') return true;
|
||||
if (paramName === 'limit') return 50;
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
getDataTableAggregateProxy: jest.fn().mockResolvedValue(mockAggregateProxy),
|
||||
} as any;
|
||||
|
||||
const mockTables = [
|
||||
{ id: 'table-1', name: 'Table 1', columns: [] },
|
||||
{ id: 'table-2', name: 'Table 2', columns: [] },
|
||||
];
|
||||
|
||||
mockAggregateProxy.getManyAndCount.mockResolvedValue({
|
||||
data: mockTables,
|
||||
count: 2,
|
||||
} as any);
|
||||
|
||||
const result = await listOperation.execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockAggregateProxy.getManyAndCount).toHaveBeenCalledWith({
|
||||
skip: 0,
|
||||
take: 100,
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ json: mockTables[0] }, { json: mockTables[1] }]);
|
||||
});
|
||||
|
||||
it('should list data tables with limit', async () => {
|
||||
const mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
const mockAggregateProxy = mock<IDataTableProjectAggregateService>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'returnAll') return false;
|
||||
if (paramName === 'limit') return 2;
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
getDataTableAggregateProxy: jest.fn().mockResolvedValue(mockAggregateProxy),
|
||||
} as any;
|
||||
|
||||
const mockTables = [
|
||||
{ id: 'table-1', name: 'Table 1', columns: [] },
|
||||
{ id: 'table-2', name: 'Table 2', columns: [] },
|
||||
];
|
||||
|
||||
mockAggregateProxy.getManyAndCount.mockResolvedValue({
|
||||
data: mockTables,
|
||||
count: 10,
|
||||
} as any);
|
||||
|
||||
const result = await listOperation.execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockAggregateProxy.getManyAndCount).toHaveBeenCalledWith({
|
||||
skip: 0,
|
||||
take: 2,
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ json: mockTables[0] }, { json: mockTables[1] }]);
|
||||
});
|
||||
|
||||
it('should filter data tables by name', async () => {
|
||||
const mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
const mockAggregateProxy = mock<IDataTableProjectAggregateService>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'returnAll') return false;
|
||||
if (paramName === 'limit') return 50;
|
||||
if (paramName === 'options') return { filterName: 'Test' };
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
getDataTableAggregateProxy: jest.fn().mockResolvedValue(mockAggregateProxy),
|
||||
} as any;
|
||||
|
||||
const mockTables = [{ id: 'table-1', name: 'Test Table', columns: [] }];
|
||||
|
||||
mockAggregateProxy.getManyAndCount.mockResolvedValue({
|
||||
data: mockTables,
|
||||
count: 1,
|
||||
} as any);
|
||||
|
||||
const result = await listOperation.execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockAggregateProxy.getManyAndCount).toHaveBeenCalledWith({
|
||||
filter: { name: 'test' },
|
||||
skip: 0,
|
||||
take: 50,
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ json: mockTables[0] }]);
|
||||
});
|
||||
|
||||
it('should sort data tables by name ascending', async () => {
|
||||
const mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
const mockAggregateProxy = mock<IDataTableProjectAggregateService>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'returnAll') return false;
|
||||
if (paramName === 'limit') return 50;
|
||||
if (paramName === 'options') return { sortField: 'name', sortDirection: 'asc' };
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
getDataTableAggregateProxy: jest.fn().mockResolvedValue(mockAggregateProxy),
|
||||
} as any;
|
||||
|
||||
const mockTables = [
|
||||
{ id: 'table-1', name: 'A Table', columns: [] },
|
||||
{ id: 'table-2', name: 'B Table', columns: [] },
|
||||
];
|
||||
|
||||
mockAggregateProxy.getManyAndCount.mockResolvedValue({
|
||||
data: mockTables,
|
||||
count: 2,
|
||||
} as any);
|
||||
|
||||
const result = await listOperation.execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockAggregateProxy.getManyAndCount).toHaveBeenCalledWith({
|
||||
sortBy: 'name:asc',
|
||||
skip: 0,
|
||||
take: 50,
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ json: mockTables[0] }, { json: mockTables[1] }]);
|
||||
});
|
||||
|
||||
it('should handle pagination for returnAll option', async () => {
|
||||
const mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
const mockAggregateProxy = mock<IDataTableProjectAggregateService>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'returnAll') return true;
|
||||
if (paramName === 'limit') return 50;
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
getDataTableAggregateProxy: jest.fn().mockResolvedValue(mockAggregateProxy),
|
||||
} as any;
|
||||
|
||||
// First page
|
||||
const firstPageTables = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: `table-${i}`,
|
||||
name: `Table ${i}`,
|
||||
columns: [],
|
||||
}));
|
||||
|
||||
// Second page (partial)
|
||||
const secondPageTables = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `table-${i + 100}`,
|
||||
name: `Table ${i + 100}`,
|
||||
columns: [],
|
||||
}));
|
||||
|
||||
mockAggregateProxy.getManyAndCount
|
||||
.mockResolvedValueOnce({
|
||||
data: firstPageTables,
|
||||
count: 150,
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
data: secondPageTables,
|
||||
count: 150,
|
||||
} as any);
|
||||
|
||||
const result = await listOperation.execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockAggregateProxy.getManyAndCount).toHaveBeenCalledTimes(2);
|
||||
expect(mockAggregateProxy.getManyAndCount).toHaveBeenNthCalledWith(1, {
|
||||
skip: 0,
|
||||
take: 100,
|
||||
});
|
||||
expect(mockAggregateProxy.getManyAndCount).toHaveBeenNthCalledWith(2, {
|
||||
skip: 100,
|
||||
take: 100,
|
||||
});
|
||||
|
||||
expect(result.length).toBe(150);
|
||||
});
|
||||
|
||||
it('should return empty array when no tables exist', async () => {
|
||||
const mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
const mockAggregateProxy = mock<IDataTableProjectAggregateService>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'returnAll') return true;
|
||||
if (paramName === 'limit') return 50;
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
getDataTableAggregateProxy: jest.fn().mockResolvedValue(mockAggregateProxy),
|
||||
} as any;
|
||||
|
||||
mockAggregateProxy.getManyAndCount.mockResolvedValue({
|
||||
data: [],
|
||||
count: 0,
|
||||
} as any);
|
||||
|
||||
const result = await listOperation.execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update Operation', () => {
|
||||
it('should update a data table name successfully', async () => {
|
||||
const mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
const mockDataTableProxy = mock<IDataTableProjectService>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'dataTableId') return 'table-123';
|
||||
if (paramName === 'newName') return 'Updated Table Name';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
getDataTableProxy: jest.fn().mockResolvedValue(mockDataTableProxy),
|
||||
} as any;
|
||||
|
||||
mockDataTableProxy.updateDataTable.mockResolvedValue(true);
|
||||
|
||||
const result = await updateOperation.execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockDataTableProxy.updateDataTable).toHaveBeenCalledWith({
|
||||
name: 'Updated Table Name',
|
||||
});
|
||||
expect(result).toEqual([{ json: { success: true, name: 'Updated Table Name' } }]);
|
||||
});
|
||||
|
||||
it('should return success false when update fails', async () => {
|
||||
const mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
const mockDataTableProxy = mock<IDataTableProjectService>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'dataTableId') return 'table-456';
|
||||
if (paramName === 'newName') return 'Failed Update';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
getDataTableProxy: jest.fn().mockResolvedValue(mockDataTableProxy),
|
||||
} as any;
|
||||
|
||||
mockDataTableProxy.updateDataTable.mockResolvedValue(false);
|
||||
|
||||
const result = await updateOperation.execute.call(mockExecuteFunctions, 0);
|
||||
|
||||
expect(mockDataTableProxy.updateDataTable).toHaveBeenCalledWith({ name: 'Failed Update' });
|
||||
expect(result).toEqual([{ json: { success: false, name: 'Failed Update' } }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user