mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(Data Table Node): Add Update, Upsert operations (no-changelog) (#18820)
This commit is contained in:
@@ -1066,7 +1066,9 @@ describe('dataStore', () => {
|
||||
]);
|
||||
|
||||
// ASSERT
|
||||
await expect(result).rejects.toThrow(new DataStoreValidationError('unknown column name'));
|
||||
await expect(result).rejects.toThrow(
|
||||
new DataStoreValidationError("unknown column name 'cWrong'"),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a invalid date string to date column', async () => {
|
||||
|
||||
@@ -15,14 +15,15 @@ import {
|
||||
ListDataStoreRowsOptions,
|
||||
MoveDataStoreColumnOptions,
|
||||
UpdateDataStoreOptions,
|
||||
UpdateDataStoreRowsOptions,
|
||||
UpsertDataStoreRowsOptions,
|
||||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
|
||||
import { DataStoreService } from './data-store.service';
|
||||
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
|
||||
@Service()
|
||||
export class DataStoreProxyService implements DataStoreProxyProvider {
|
||||
constructor(
|
||||
@@ -134,6 +135,10 @@ export class DataStoreProxyService implements DataStoreProxyProvider {
|
||||
return await dataStoreService.insertRows(dataStoreId, projectId, rows, true);
|
||||
},
|
||||
|
||||
async updateRows(options: UpdateDataStoreRowsOptions) {
|
||||
return await dataStoreService.updateRow(dataStoreId, projectId, options, true);
|
||||
},
|
||||
|
||||
async upsertRows(options: UpsertDataStoreRowsOptions) {
|
||||
return await dataStoreService.upsertRows(dataStoreId, projectId, options, true);
|
||||
},
|
||||
|
||||
@@ -176,6 +176,12 @@ export class DataStoreService {
|
||||
);
|
||||
}
|
||||
|
||||
async updateRow<T extends boolean | undefined>(
|
||||
dataStoreId: string,
|
||||
projectId: string,
|
||||
dto: Omit<UpdateDataStoreRowDto, 'returnData'>,
|
||||
returnData?: T,
|
||||
): Promise<T extends true ? DataStoreRowReturn[] : true>;
|
||||
async updateRow(
|
||||
dataStoreId: string,
|
||||
projectId: string,
|
||||
@@ -225,7 +231,12 @@ export class DataStoreService {
|
||||
): void {
|
||||
// Include system columns like 'id' if requested
|
||||
const allColumns = includeSystemColumns
|
||||
? [{ name: 'id', type: 'number' }, ...columns]
|
||||
? [
|
||||
{ name: 'id', type: 'number' },
|
||||
{ name: 'createdAt', type: 'date' },
|
||||
{ name: 'updatedAt', type: 'date' },
|
||||
...columns,
|
||||
]
|
||||
: columns;
|
||||
const columnNames = new Set(allColumns.map((x) => x.name));
|
||||
const columnTypeMap = new Map(allColumns.map((x) => [x.name, x.type]));
|
||||
@@ -236,7 +247,7 @@ export class DataStoreService {
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (!columnNames.has(key)) {
|
||||
throw new DataStoreValidationError('unknown column name');
|
||||
throw new DataStoreValidationError(`unknown column name '${key}'`);
|
||||
}
|
||||
this.validateCell(row, key, columnTypeMap);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import * as row from './row/Row.resource';
|
||||
|
||||
type DataTableNodeType = AllEntities<{ row: 'insert' | 'get' | 'deleteRows' }>;
|
||||
type DataTableNodeType = AllEntities<{
|
||||
row: 'insert' | 'get' | 'deleteRows' | 'update' | 'upsert';
|
||||
}>;
|
||||
|
||||
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const operationResult: INodeExecutionData[] = [];
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { INodeProperties } from 'n8n-workflow';
|
||||
import * as deleteRows from './delete.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_ID_FIELD } from '../../common/fields';
|
||||
|
||||
export { insert, get, deleteRows };
|
||||
export { insert, get, deleteRows, update, upsert };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
@@ -49,6 +51,18 @@ export const description: INodeProperties[] = [
|
||||
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',
|
||||
},
|
||||
@@ -79,4 +93,6 @@ export const description: INodeProperties[] = [
|
||||
...deleteRows.description,
|
||||
...insert.description,
|
||||
...get.description,
|
||||
...update.description,
|
||||
...upsert.description,
|
||||
];
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type {
|
||||
IDisplayOptions,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { COLUMNS } from '../../common/fields';
|
||||
import { dataObjectToApiInput, getDataTableProxyExecute } from '../../common/utils';
|
||||
import { getAddRow, makeAddRow } from '../../common/addRow';
|
||||
import { getDataTableProxyExecute } from '../../common/utils';
|
||||
|
||||
export const FIELD: string = 'insert';
|
||||
|
||||
@@ -18,34 +17,16 @@ const displayOptions: IDisplayOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...COLUMNS,
|
||||
displayOptions,
|
||||
},
|
||||
];
|
||||
export const description: INodeProperties[] = [makeAddRow(FIELD, displayOptions)];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
|
||||
const dataStoreProxy = await getDataTableProxyExecute(this, index);
|
||||
const dataMode = this.getNodeParameter('columns.mappingMode', index) as string;
|
||||
|
||||
let data: IDataObject;
|
||||
const row = getAddRow(this, index);
|
||||
|
||||
if (dataMode === 'autoMapInputData') {
|
||||
data = items[index].json;
|
||||
} else {
|
||||
const fields = this.getNodeParameter('columns.value', index, {}) as IDataObject;
|
||||
|
||||
data = fields;
|
||||
}
|
||||
|
||||
const rows = dataObjectToApiInput(data, this.getNode(), index);
|
||||
|
||||
const insertedRowIds = await dataStoreProxy.insertRows([rows]);
|
||||
return insertedRowIds.map((x) => ({ json: { id: x } }));
|
||||
const insertedRows = await dataStoreProxy.insertRows([row]);
|
||||
return insertedRows.map((json) => ({ json }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
NodeOperationError,
|
||||
type IDisplayOptions,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { makeAddRow, getAddRow } from '../../common/addRow';
|
||||
import { executeSelectMany, getSelectFields } from '../../common/selectMany';
|
||||
import { getDataTableProxyExecute } 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),
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const dataStoreProxy = await getDataTableProxyExecute(this, index);
|
||||
|
||||
const row = getAddRow(this, index);
|
||||
|
||||
const matches = await executeSelectMany(this, index, dataStoreProxy, true);
|
||||
|
||||
const result = [];
|
||||
for (const x of matches) {
|
||||
const updatedRows = await dataStoreProxy.updateRows({
|
||||
data: row,
|
||||
filter: { id: x.json.id },
|
||||
});
|
||||
if (updatedRows.length !== 1) {
|
||||
throw new NodeOperationError(this.getNode(), 'invariant broken');
|
||||
}
|
||||
|
||||
// The input object gets updated in the api call, somehow
|
||||
// And providing this column to the backend causes an unexpected column error
|
||||
// So let's just re-delete the field until we have a more aligned API
|
||||
delete row['updatedAt'];
|
||||
|
||||
result.push(updatedRows[0]);
|
||||
}
|
||||
|
||||
return result.map((json) => ({ json }));
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
NodeOperationError,
|
||||
type IDisplayOptions,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { makeAddRow, getAddRow } from '../../common/addRow';
|
||||
import { executeSelectMany, getSelectFields } from '../../common/selectMany';
|
||||
import { getDataTableProxyExecute } from '../../common/utils';
|
||||
|
||||
export const FIELD: string = 'upsert';
|
||||
|
||||
const displayOptions: IDisplayOptions = {
|
||||
show: {
|
||||
resource: ['row'],
|
||||
operation: [FIELD],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
...getSelectFields(displayOptions),
|
||||
makeAddRow(FIELD, displayOptions),
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const dataStoreProxy = await getDataTableProxyExecute(this, index);
|
||||
|
||||
const row = getAddRow(this, index);
|
||||
|
||||
const matches = await executeSelectMany(this, index, dataStoreProxy, true);
|
||||
|
||||
// insert
|
||||
if (matches.length === 0) {
|
||||
const result = await dataStoreProxy.insertRows([row]);
|
||||
return result.map((json) => ({ json }));
|
||||
}
|
||||
|
||||
// update
|
||||
const result = [];
|
||||
for (const match of matches) {
|
||||
const updatedRows = await dataStoreProxy.updateRows({
|
||||
data: row,
|
||||
filter: { id: match.json.id },
|
||||
});
|
||||
if (updatedRows.length !== 1) {
|
||||
throw new NodeOperationError(this.getNode(), 'invariant broken');
|
||||
}
|
||||
|
||||
// The input object gets updated in the api call, somehow
|
||||
// And providing this column to the backend causes an unexpected column error
|
||||
// So let's just re-delete the field until we have a more aligned API
|
||||
delete row['updatedAt'];
|
||||
|
||||
result.push(updatedRows[0]);
|
||||
}
|
||||
|
||||
return result.map((json) => ({ json }));
|
||||
}
|
||||
55
packages/nodes-base/nodes/DataTable/common/addRow.ts
Normal file
55
packages/nodes-base/nodes/DataTable/common/addRow.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IDisplayOptions,
|
||||
IExecuteFunctions,
|
||||
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: `Columns to ${operation}`,
|
||||
resourceMapperMethod: 'getDataTables',
|
||||
mode: 'add',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: 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;
|
||||
} else {
|
||||
const fields = ctx.getNodeParameter('columns.value', index, {}) as IDataObject;
|
||||
|
||||
data = fields;
|
||||
}
|
||||
|
||||
return dataObjectToApiInput(data, ctx.getNode(), index);
|
||||
}
|
||||
@@ -2,36 +2,11 @@ import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const DATA_TABLE_ID_FIELD = 'dataTableId';
|
||||
|
||||
export const COLUMNS = {
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
noDataExpression: true,
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: [`${DATA_TABLE_ID_FIELD}.value`],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getDataTables',
|
||||
mode: 'add',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: true,
|
||||
},
|
||||
},
|
||||
} satisfies INodeProperties;
|
||||
|
||||
export const DRY_RUN = {
|
||||
displayName: 'Dry Run',
|
||||
name: 'dryRun',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether the delete operation should only be simulated, returning the rows that would have been deleted',
|
||||
'Whether the operation should only be simulated, returning the rows that would have been affected',
|
||||
} satisfies INodeProperties;
|
||||
|
||||
@@ -11,7 +11,10 @@ import type { FilterType } from './constants';
|
||||
import { DATA_TABLE_ID_FIELD } from './fields';
|
||||
import { buildGetManyFilter, isFieldArray, isMatchType } from './utils';
|
||||
|
||||
export function getSelectFields(displayOptions: IDisplayOptions): INodeProperties[] {
|
||||
export function getSelectFields(
|
||||
displayOptions: IDisplayOptions,
|
||||
requireCondition = false,
|
||||
): INodeProperties[] {
|
||||
return [
|
||||
{
|
||||
displayName: 'Must Match',
|
||||
@@ -36,6 +39,7 @@ export function getSelectFields(displayOptions: IDisplayOptions): INodePropertie
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
minRequiredFields: requireCondition ? 1 : 0,
|
||||
},
|
||||
displayOptions,
|
||||
default: {},
|
||||
@@ -105,9 +109,14 @@ export async function executeSelectMany(
|
||||
ctx: IExecuteFunctions,
|
||||
index: number,
|
||||
dataStoreProxy: IDataStoreProjectService,
|
||||
rejectEmpty = false,
|
||||
): Promise<Array<{ json: DataStoreRowReturn }>> {
|
||||
const filter = getSelectFilter(ctx, index);
|
||||
|
||||
if (rejectEmpty && filter.filters.length === 0) {
|
||||
throw new NodeOperationError(ctx.getNode(), 'At least one condition is required');
|
||||
}
|
||||
|
||||
let take = 1000;
|
||||
const result: Array<{ json: DataStoreRowReturn }> = [];
|
||||
let totalCount = undefined;
|
||||
|
||||
@@ -57,6 +57,11 @@ export type ListDataStoreRowsOptions = {
|
||||
skip?: number;
|
||||
};
|
||||
|
||||
export type UpdateDataStoreRowsOptions = {
|
||||
filter: Record<string, DataStoreColumnJsType>;
|
||||
data: DataStoreRow;
|
||||
};
|
||||
|
||||
export type UpsertDataStoreRowsOptions = {
|
||||
rows: DataStoreRows;
|
||||
matchFields: string[];
|
||||
@@ -113,6 +118,8 @@ export interface IDataStoreProjectService {
|
||||
|
||||
insertRows(rows: DataStoreRows): Promise<DataStoreRowReturn[]>;
|
||||
|
||||
updateRows(options: UpdateDataStoreRowsOptions): Promise<DataStoreRowReturn[]>;
|
||||
|
||||
upsertRows(options: UpsertDataStoreRowsOptions): Promise<DataStoreRowReturn[]>;
|
||||
|
||||
deleteRows(ids: number[]): Promise<boolean>;
|
||||
|
||||
Reference in New Issue
Block a user