diff --git a/packages/@n8n/api-types/src/dto/data-store/delete-data-store-rows-query.dto.ts b/packages/@n8n/api-types/src/dto/data-store/delete-data-store-rows-query.dto.ts new file mode 100644 index 0000000000..841e065d5b --- /dev/null +++ b/packages/@n8n/api-types/src/dto/data-store/delete-data-store-rows-query.dto.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class DeleteDataStoreRowsQueryDto extends Z.class({ + ids: z + .string() + .transform((str) => { + if (!str.trim()) return []; + return str.split(',').map((id) => parseInt(id.trim(), 10)); + }) + .refine((ids) => ids.length === 0 || ids.every((id) => !isNaN(id) && id > 0), { + message: 'All ids must be positive integers', + }), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index f92100800a..5aacc2c5ed 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -90,3 +90,4 @@ export { CreateDataStoreColumnDto } from './data-store/create-data-store-column. export { AddDataStoreRowsDto } from './data-store/add-data-store-rows.dto'; export { AddDataStoreColumnDto } from './data-store/add-data-store-column.dto'; export { MoveDataStoreColumnDto } from './data-store/move-data-store-column.dto'; +export { DeleteDataStoreRowsQueryDto } from './data-store/delete-data-store-rows-query.dto'; diff --git a/packages/cli/src/modules/data-store/__tests__/data-store.controller.test.ts b/packages/cli/src/modules/data-store/__tests__/data-store.controller.test.ts index 37b6dd8a2e..916636bd8f 100644 --- a/packages/cli/src/modules/data-store/__tests__/data-store.controller.test.ts +++ b/packages/cli/src/modules/data-store/__tests__/data-store.controller.test.ts @@ -1942,6 +1942,318 @@ describe('POST /projects/:projectId/data-stores/:dataStoreId/insert', () => { }); }); +describe('DELETE /projects/:projectId/data-stores/:dataStoreId/rows', () => { + test('should not delete rows when project does not exist', async () => { + await authOwnerAgent + .delete('/projects/non-existing-id/data-stores/some-data-store-id/rows') + .query({ ids: '1,2' }) + .expect(403); + }); + + test('should not delete rows when data store does not exist', async () => { + const project = await createTeamProject('test project', owner); + + await authOwnerAgent + .delete(`/projects/${project.id}/data-stores/non-existing-id/rows`) + .query({ ids: '1,2' }) + .expect(404); + }); + + test("should not delete rows in another user's personal project data store", async () => { + const dataStore = await createDataStore(ownerProject, { + columns: [ + { + name: 'first', + type: 'string', + }, + { + name: 'second', + type: 'string', + }, + ], + data: [ + { + first: 'test value', + second: 'another value', + }, + ], + }); + + await authMemberAgent + .delete(`/projects/${ownerProject.id}/data-stores/${dataStore.id}/rows`) + .query({ ids: '1' }) + .expect(403); + + const rowsInDb = await dataStoreRowsRepository.getManyAndCount(toTableName(dataStore.id), {}); + expect(rowsInDb.count).toBe(1); + }); + + test('should not delete rows if user has project:viewer role in team project', async () => { + const project = await createTeamProject('test project', owner); + await linkUserToProject(member, project, 'project:viewer'); + const dataStore = await createDataStore(project, { + columns: [ + { + name: 'first', + type: 'string', + }, + { + name: 'second', + type: 'string', + }, + ], + data: [ + { + first: 'test value', + second: 'another value', + }, + ], + }); + + await authMemberAgent + .delete(`/projects/${project.id}/data-stores/${dataStore.id}/rows`) + .query({ ids: '1' }) + .expect(403); + + const rowsInDb = await dataStoreRowsRepository.getManyAndCount(toTableName(dataStore.id), {}); + expect(rowsInDb.count).toBe(1); + }); + + test('should delete rows if user has project:editor role in team project', async () => { + const project = await createTeamProject('test project', owner); + await linkUserToProject(member, project, 'project:editor'); + + const dataStore = await createDataStore(project, { + columns: [ + { + name: 'first', + type: 'string', + }, + { + name: 'second', + type: 'string', + }, + ], + data: [ + { + first: 'test value 1', + second: 'another value 1', + }, + { + first: 'test value 2', + second: 'another value 2', + }, + { + first: 'test value 3', + second: 'another value 3', + }, + ], + }); + + await authMemberAgent + .delete(`/projects/${project.id}/data-stores/${dataStore.id}/rows`) + .query({ ids: '1,3' }) + .expect(200); + + const rowsInDb = await dataStoreRowsRepository.getManyAndCount(toTableName(dataStore.id), {}); + expect(rowsInDb.count).toBe(1); + expect(rowsInDb.data[0]).toMatchObject({ + first: 'test value 2', + second: 'another value 2', + }); + }); + + test('should delete rows if user has project:admin role in team project', async () => { + const project = await createTeamProject('test project', owner); + await linkUserToProject(admin, project, 'project:admin'); + + const dataStore = await createDataStore(project, { + columns: [ + { + name: 'first', + type: 'string', + }, + { + name: 'second', + type: 'string', + }, + ], + data: [ + { + first: 'test value 1', + second: 'another value 1', + }, + { + first: 'test value 2', + second: 'another value 2', + }, + ], + }); + + await authAdminAgent + .delete(`/projects/${project.id}/data-stores/${dataStore.id}/rows`) + .query({ ids: '2' }) + .expect(200); + + const rowsInDb = await dataStoreRowsRepository.getManyAndCount(toTableName(dataStore.id), {}); + expect(rowsInDb.count).toBe(1); + expect(rowsInDb.data[0]).toMatchObject({ + first: 'test value 1', + second: 'another value 1', + }); + }); + + test('should delete rows if user is owner in team project', async () => { + const project = await createTeamProject('test project', owner); + + const dataStore = await createDataStore(project, { + columns: [ + { + name: 'first', + type: 'string', + }, + { + name: 'second', + type: 'string', + }, + ], + data: [ + { + first: 'test value 1', + second: 'another value 1', + }, + { + first: 'test value 2', + second: 'another value 2', + }, + ], + }); + + await authOwnerAgent + .delete(`/projects/${project.id}/data-stores/${dataStore.id}/rows`) + .query({ ids: '1,2' }) + .expect(200); + + const rowsInDb = await dataStoreRowsRepository.getManyAndCount(toTableName(dataStore.id), {}); + expect(rowsInDb.count).toBe(0); + }); + + test('should delete rows in personal project', async () => { + const dataStore = await createDataStore(memberProject, { + columns: [ + { + name: 'first', + type: 'string', + }, + { + name: 'second', + type: 'string', + }, + ], + data: [ + { + first: 'test value 1', + second: 'another value 1', + }, + { + first: 'test value 2', + second: 'another value 2', + }, + { + first: 'test value 3', + second: 'another value 3', + }, + ], + }); + + await authMemberAgent + .delete(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`) + .query({ ids: '2' }) + .expect(200); + + const rowsInDb = await dataStoreRowsRepository.getManyAndCount(toTableName(dataStore.id), {}); + expect(rowsInDb.count).toBe(2); + expect(rowsInDb.data.map((r) => r.first).sort()).toEqual(['test value 1', 'test value 3']); + }); + + test('should return true when deleting empty ids array', async () => { + const dataStore = await createDataStore(memberProject, { + columns: [ + { + name: 'first', + type: 'string', + }, + ], + data: [ + { + first: 'test value', + }, + ], + }); + + const response = await authMemberAgent + .delete(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`) + .query({ ids: '' }) + .expect(200); + + expect(response.body.data).toBe(true); + + const rowsInDb = await dataStoreRowsRepository.getManyAndCount(toTableName(dataStore.id), {}); + expect(rowsInDb.count).toBe(1); + }); + + test('should handle deletion of non-existing row ids gracefully', async () => { + const dataStore = await createDataStore(memberProject, { + columns: [ + { + name: 'first', + type: 'string', + }, + ], + data: [ + { + first: 'test value', + }, + ], + }); + + await authMemberAgent + .delete(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`) + .query({ ids: '999,1000' }) + .expect(200); + + const rowsInDb = await dataStoreRowsRepository.getManyAndCount(toTableName(dataStore.id), {}); + expect(rowsInDb.count).toBe(1); + }); + + test('should handle mixed existing and non-existing row ids', async () => { + const dataStore = await createDataStore(memberProject, { + columns: [ + { + name: 'first', + type: 'string', + }, + ], + data: [ + { + first: 'test value 1', + }, + { + first: 'test value 2', + }, + ], + }); + + await authMemberAgent + .delete(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`) + .query({ ids: '1,999,2,1000' }) + .expect(200); + + const rowsInDb = await dataStoreRowsRepository.getManyAndCount(toTableName(dataStore.id), {}); + expect(rowsInDb.count).toBe(0); + }); +}); + describe('POST /projects/:projectId/data-stores/:dataStoreId/upsert', () => { test('should not upsert rows when project does not exist', async () => { const payload = { diff --git a/packages/cli/src/modules/data-store/__tests__/data-store.service.test.ts b/packages/cli/src/modules/data-store/__tests__/data-store.service.test.ts index 9461111fc4..1aba433c87 100644 --- a/packages/cli/src/modules/data-store/__tests__/data-store.service.test.ts +++ b/packages/cli/src/modules/data-store/__tests__/data-store.service.test.ts @@ -1131,6 +1131,84 @@ describe('dataStore', () => { }); }); + describe('deleteRows', () => { + it('deletes rows by IDs', async () => { + // ARRANGE + const dataStore = await dataStoreService.createDataStore(project1.id, { + name: 'dataStore', + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }); + const { id: dataStoreId } = dataStore; + + // Insert test rows + await dataStoreService.insertRows(dataStoreId, project1.id, [ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 25 }, + { name: 'Charlie', age: 35 }, + ]); + + // Get initial data to find row IDs + const initialData = await dataStoreService.getManyRowsAndCount(dataStoreId, project1.id, {}); + expect(initialData.count).toBe(3); + + // ACT - Delete first and third rows + const result = await dataStoreService.deleteRows(dataStoreId, project1.id, [1, 3]); + + // ASSERT + expect(result).toBe(true); + + const rows = await dataStoreService.getManyRowsAndCount(dataStoreId, project1.id, {}); + expect(rows.count).toBe(1); + expect(rows.data).toEqual([{ name: 'Bob', age: 25, id: 2 }]); + }); + + it('returns true when deleting empty list of IDs', async () => { + // ARRANGE + const dataStore = await dataStoreService.createDataStore(project1.id, { + name: 'dataStore', + columns: [{ name: 'name', type: 'string' }], + }); + const { id: dataStoreId } = dataStore; + + // ACT + const result = await dataStoreService.deleteRows(dataStoreId, project1.id, []); + + // ASSERT + expect(result).toBe(true); + }); + + it('fails when trying to delete from non-existent data store', async () => { + // ACT & ASSERT + const result = dataStoreService.deleteRows('non-existent-id', project1.id, [1, 2]); + + await expect(result).rejects.toThrow(DataStoreNotFoundError); + }); + + it('succeeds even if some IDs do not exist', async () => { + // ARRANGE + const dataStore = await dataStoreService.createDataStore(project1.id, { + name: 'dataStore', + columns: [{ name: 'name', type: 'string' }], + }); + const { id: dataStoreId } = dataStore; + + // Insert one row + await dataStoreService.insertRows(dataStoreId, project1.id, [{ name: 'Alice' }]); + + // ACT - Try to delete existing and non-existing IDs + const result = await dataStoreService.deleteRows(dataStoreId, project1.id, [1, 999, 1000]); + + // ASSERT + expect(result).toBe(true); + + const { count } = await dataStoreService.getManyRowsAndCount(dataStoreId, project1.id, {}); + expect(count).toBe(0); + }); + }); + describe('getManyRowsAndCount', () => { it('retrieves rows correctly', async () => { // ARRANGE diff --git a/packages/cli/src/modules/data-store/data-store-rows.repository.ts b/packages/cli/src/modules/data-store/data-store-rows.repository.ts index ad26e57b4b..8d4c5f39cf 100644 --- a/packages/cli/src/modules/data-store/data-store-rows.repository.ts +++ b/packages/cli/src/modules/data-store/data-store-rows.repository.ts @@ -98,6 +98,20 @@ export class DataStoreRowsRepository { return true; } + async deleteRows(tableName: DataStoreUserTableName, ids: number[]) { + if (ids.length === 0) { + return true; + } + + const dbType = this.dataSource.options.type; + const quotedTableName = quoteIdentifier(tableName, dbType); + const placeholders = ids.map((_, index) => getPlaceholder(index + 1, dbType)).join(', '); + const query = `DELETE FROM ${quotedTableName} WHERE id IN (${placeholders})`; + + await this.dataSource.query(query, ids); + return true; + } + async createTableWithColumns( tableName: string, columns: DataStoreColumn[], diff --git a/packages/cli/src/modules/data-store/data-store.controller.ts b/packages/cli/src/modules/data-store/data-store.controller.ts index 4a74436e98..6a9592080b 100644 --- a/packages/cli/src/modules/data-store/data-store.controller.ts +++ b/packages/cli/src/modules/data-store/data-store.controller.ts @@ -2,6 +2,7 @@ import { AddDataStoreRowsDto, AddDataStoreColumnDto, CreateDataStoreDto, + DeleteDataStoreRowsQueryDto, ListDataStoreContentQueryDto, ListDataStoreQueryDto, MoveDataStoreColumnDto, @@ -277,4 +278,26 @@ export class DataStoreController { } } } + + @Delete('/:dataStoreId/rows') + @ProjectScope('dataStore:writeRow') + async deleteDataStoreRows( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('dataStoreId') dataStoreId: string, + @Query dto: DeleteDataStoreRowsQueryDto, + ) { + try { + const { ids } = dto; + return await this.dataStoreService.deleteRows(dataStoreId, req.params.projectId, ids); + } catch (e: unknown) { + if (e instanceof DataStoreNotFoundError) { + throw new NotFoundError(e.message); + } else if (e instanceof Error) { + throw new InternalServerError(e.message, e); + } else { + throw e; + } + } + } } diff --git a/packages/cli/src/modules/data-store/data-store.service.ts b/packages/cli/src/modules/data-store/data-store.service.ts index 0dbf8a03e8..59e26b179a 100644 --- a/packages/cli/src/modules/data-store/data-store.service.ts +++ b/packages/cli/src/modules/data-store/data-store.service.ts @@ -143,6 +143,12 @@ export class DataStoreService { return await this.dataStoreRowsRepository.upsertRows(toTableName(dataStoreId), dto, columns); } + async deleteRows(dataStoreId: string, projectId: string, ids: number[]) { + await this.validateDataStoreExists(dataStoreId, projectId); + + return await this.dataStoreRowsRepository.deleteRows(toTableName(dataStoreId), ids); + } + private async validateRows(dataStoreId: string, rows: DataStoreRows): Promise { const columns = await this.dataStoreColumnRepository.getColumns(dataStoreId); if (columns.length === 0) {