diff --git a/packages/cli/src/modules/data-table/__tests__/data-store.service.test.ts b/packages/cli/src/modules/data-table/__tests__/data-store.service.test.ts index 024bfc80cc..65f2f5b154 100644 --- a/packages/cli/src/modules/data-table/__tests__/data-store.service.test.ts +++ b/packages/cli/src/modules/data-table/__tests__/data-store.service.test.ts @@ -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 () => { diff --git a/packages/cli/src/modules/data-table/data-store-proxy.service.ts b/packages/cli/src/modules/data-table/data-store-proxy.service.ts index b5e9afbaa7..fc04344803 100644 --- a/packages/cli/src/modules/data-table/data-store-proxy.service.ts +++ b/packages/cli/src/modules/data-table/data-store-proxy.service.ts @@ -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); }, diff --git a/packages/cli/src/modules/data-table/data-store.service.ts b/packages/cli/src/modules/data-table/data-store.service.ts index f063b9b961..c9f3b1f31f 100644 --- a/packages/cli/src/modules/data-table/data-store.service.ts +++ b/packages/cli/src/modules/data-table/data-store.service.ts @@ -176,6 +176,12 @@ export class DataStoreService { ); } + async updateRow( + dataStoreId: string, + projectId: string, + dto: Omit, + returnData?: T, + ): Promise; 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); } diff --git a/packages/nodes-base/nodes/DataTable/actions/router.ts b/packages/nodes-base/nodes/DataTable/actions/router.ts index 4affd9f4d8..490b2a0707 100644 --- a/packages/nodes-base/nodes/DataTable/actions/router.ts +++ b/packages/nodes-base/nodes/DataTable/actions/router.ts @@ -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 { const operationResult: INodeExecutionData[] = []; diff --git a/packages/nodes-base/nodes/DataTable/actions/row/Row.resource.ts b/packages/nodes-base/nodes/DataTable/actions/row/Row.resource.ts index f1ad1a52a8..dfd40d6823 100644 --- a/packages/nodes-base/nodes/DataTable/actions/row/Row.resource.ts +++ b/packages/nodes-base/nodes/DataTable/actions/row/Row.resource.ts @@ -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, ]; diff --git a/packages/nodes-base/nodes/DataTable/actions/row/insert.operation.ts b/packages/nodes-base/nodes/DataTable/actions/row/insert.operation.ts index 49a215d512..83b2be2c26 100644 --- a/packages/nodes-base/nodes/DataTable/actions/row/insert.operation.ts +++ b/packages/nodes-base/nodes/DataTable/actions/row/insert.operation.ts @@ -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 { - 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 })); } diff --git a/packages/nodes-base/nodes/DataTable/actions/row/update.operation.ts b/packages/nodes-base/nodes/DataTable/actions/row/update.operation.ts new file mode 100644 index 0000000000..5176012445 --- /dev/null +++ b/packages/nodes-base/nodes/DataTable/actions/row/update.operation.ts @@ -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 { + 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 })); +} diff --git a/packages/nodes-base/nodes/DataTable/actions/row/upsert.operation.ts b/packages/nodes-base/nodes/DataTable/actions/row/upsert.operation.ts new file mode 100644 index 0000000000..6f25ba62b1 --- /dev/null +++ b/packages/nodes-base/nodes/DataTable/actions/row/upsert.operation.ts @@ -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 { + 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 })); +} diff --git a/packages/nodes-base/nodes/DataTable/common/addRow.ts b/packages/nodes-base/nodes/DataTable/common/addRow.ts new file mode 100644 index 0000000000..fcd81056c6 --- /dev/null +++ b/packages/nodes-base/nodes/DataTable/common/addRow.ts @@ -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); +} diff --git a/packages/nodes-base/nodes/DataTable/common/fields.ts b/packages/nodes-base/nodes/DataTable/common/fields.ts index 60a9231316..57d06993a6 100644 --- a/packages/nodes-base/nodes/DataTable/common/fields.ts +++ b/packages/nodes-base/nodes/DataTable/common/fields.ts @@ -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; diff --git a/packages/nodes-base/nodes/DataTable/common/selectMany.ts b/packages/nodes-base/nodes/DataTable/common/selectMany.ts index 11ec68be09..949f3207a8 100644 --- a/packages/nodes-base/nodes/DataTable/common/selectMany.ts +++ b/packages/nodes-base/nodes/DataTable/common/selectMany.ts @@ -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> { 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; diff --git a/packages/workflow/src/data-store.types.ts b/packages/workflow/src/data-store.types.ts index e1c6fba698..2c9c2d958b 100644 --- a/packages/workflow/src/data-store.types.ts +++ b/packages/workflow/src/data-store.types.ts @@ -57,6 +57,11 @@ export type ListDataStoreRowsOptions = { skip?: number; }; +export type UpdateDataStoreRowsOptions = { + filter: Record; + data: DataStoreRow; +}; + export type UpsertDataStoreRowsOptions = { rows: DataStoreRows; matchFields: string[]; @@ -113,6 +118,8 @@ export interface IDataStoreProjectService { insertRows(rows: DataStoreRows): Promise; + updateRows(options: UpdateDataStoreRowsOptions): Promise; + upsertRows(options: UpsertDataStoreRowsOptions): Promise; deleteRows(ids: number[]): Promise;