refactor(API): Migrate remaining data-tables row endpoints to PublicApiController (no-changelog) (#39985)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Sam Wooler 2026-10-02 08:32:19 +00:00 committed by GitHub
parent c5658ba4bf
commit 1b294221b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 891 additions and 779 deletions

View File

@ -1,8 +1,10 @@
import '../../openapi-extend';
import { jsonParse } from 'n8n-workflow';
import { z } from 'zod';
import {
clearDataTableRowsFieldDocs,
createDataTableColumnFieldDocs,
createDataTableFieldDocs,
createDataTableRowsFieldDocs,
@ -11,13 +13,16 @@ import {
dataTableListFieldDocs,
dataTableRowFieldDocs,
dataTableRowListFieldDocs,
deleteDataTableRowsQueryDocs,
updateDataTableColumnFieldDocs,
updateDataTableFieldDocs,
updateDataTableRowFieldDocs,
upsertDataTableRowFieldDocs,
} from './data-table-public.openapi';
import { booleanFromString } from '../../schemas/boolean-from-string';
import {
FilterConditionSchema,
dataTableFilterRecordSchema,
dataTableFilterSchema,
dataTableFilterTypeSchema,
} from '../../schemas/data-table-filter.schema';
import {
@ -173,15 +178,26 @@ export class InsertDataTableRowsResponsePublicDto {
}
}
const publicUpsertFilterSchema = z
.object({
type: dataTableFilterTypeSchema.default('and'),
filters: z.array(dataTableFilterRecordSchema.extend({ condition: FilterConditionSchema })),
})
.refine((filter) => filter.filters.length > 0, { message: 'filter must not be empty' });
const legacyFilterConditionSchema = z.union([
z.literal('eq'),
z.literal('neq'),
z.literal('like'),
z.literal('ilike'),
z.literal('gt'),
z.literal('gte'),
z.literal('lt'),
z.literal('lte'),
]);
const publicUpsertUpdateFilterSchema = z.object({
type: dataTableFilterTypeSchema.default('and'),
filters: z
.array(dataTableFilterRecordSchema.extend({ condition: legacyFilterConditionSchema }))
.min(1, 'filter must not be empty'),
});
export class UpsertDataTableRowPublicDto extends Z.class({
filter: publicUpsertFilterSchema.openapi(upsertDataTableRowFieldDocs.filter),
filter: publicUpsertUpdateFilterSchema.openapi(upsertDataTableRowFieldDocs.filter),
data: z
.record(dataTableColumnNameSchema, dataTableColumnValueSchema)
.refine((obj) => Object.keys(obj).length > 0, { message: 'data must not be empty' })
@ -222,3 +238,108 @@ export class UpsertDataTableRowResponsePublicDto {
return upsertDataTableRowResponseSchema.safeParse(data);
}
}
export class UpdateDataTableRowPublicDto extends Z.class({
filter: publicUpsertUpdateFilterSchema.openapi(updateDataTableRowFieldDocs.filter),
data: z
.record(dataTableColumnNameSchema, dataTableColumnValueSchema)
.refine((obj) => Object.keys(obj).length > 0, { message: 'data must not be empty' })
.openapi(updateDataTableRowFieldDocs.data),
returnData: z.boolean().optional().default(false).openapi(updateDataTableRowFieldDocs.returnData),
dryRun: z.boolean().optional().default(false).openapi(updateDataTableRowFieldDocs.dryRun),
}) {}
const updateDataTableRowResponseSchema = z.union([
z.boolean().openapi({ description: 'Returned when returnData is false and dryRun is false' }),
z.array(dataTableRowWithStatePublicSchema).openapi({
description: 'Returned when dryRun is true: one entry per row per before/after state',
}),
z
.array(dataTableRowPublicSchema)
.openapi({ description: 'Returned when returnData is true and dryRun is false' }),
]);
export class UpdateDataTableRowResponsePublicDto {
static schema = updateDataTableRowResponseSchema;
static parse(data: unknown) {
return updateDataTableRowResponseSchema.parse(data);
}
static safeParse(data: unknown) {
return updateDataTableRowResponseSchema.safeParse(data);
}
}
const publicRowFilterQueryValidator = z.string().transform((val, ctx) => {
let parsed: unknown;
try {
parsed = jsonParse(val);
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid filter format',
path: ['filter'],
});
return z.NEVER;
}
const result = dataTableFilterSchema.safeParse(parsed);
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid filter fields',
path: ['filter'],
});
return z.NEVER;
}
if (result.data.filters.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'At least one filter condition is required for delete operations',
path: ['filter'],
});
return z.NEVER;
}
return result.data;
});
export class DeleteDataTableRowsPublicQueryDto extends Z.class({
filter: publicRowFilterQueryValidator.openapi(deleteDataTableRowsQueryDocs.filter),
returnData: booleanFromString
.optional()
.default('false')
.openapi(deleteDataTableRowsQueryDocs.returnData),
dryRun: booleanFromString
.optional()
.default('false')
.openapi(deleteDataTableRowsQueryDocs.dryRun),
}) {}
const deleteDataTableRowsResponseSchema = z.union([
z.boolean().openapi({ description: 'Returned when returnData is false and dryRun is false' }),
z.array(dataTableRowWithStatePublicSchema).openapi({
description: 'Returned when dryRun is true: one entry per row per before/after state',
}),
z
.array(dataTableRowPublicSchema)
.openapi({ description: 'Returned when returnData is true and dryRun is false' }),
]);
export class DeleteDataTableRowsResponsePublicDto {
static schema = deleteDataTableRowsResponseSchema;
static parse(data: unknown) {
return deleteDataTableRowsResponseSchema.parse(data);
}
static safeParse(data: unknown) {
return deleteDataTableRowsResponseSchema.safeParse(data);
}
}
export class ClearDataTableRowsResponsePublicDto extends Z.class({
deletedCount: z.number().int().openapi(clearDataTableRowsFieldDocs.deletedCount),
}) {}

View File

@ -155,8 +155,43 @@ export const upsertDataTableRowFieldDocs = {
data: { description: 'Column values for the row' },
returnData: {
description:
'Set to true to return the upserted row(s) matched by the filter. Set to false to return ' +
'true on success.',
'Set to true to return the upserted row(s): the matching rows if updated, or the new ' +
'row if one was inserted. Set to false to return true on success.',
},
dryRun: { description: 'Set to true to preview the change without saving it.' },
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const updateDataTableRowFieldDocs = {
filter: { description: 'Filter conditions to match rows for update.' },
data: { description: 'Column values to update' },
returnData: {
description:
'Set to true to return the updated row(s). Set to false to return true on success.',
},
dryRun: { description: 'Set to true to preview the change without saving it.' },
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const deleteDataTableRowsQueryDocs = {
filter: {
type: 'string',
format: 'jsonString',
param: {
description:
'JSON string of filter conditions. Required to prevent accidental deletion of all data.',
example:
'{"type":"and","filters":[{"columnName":"status","condition":"eq","value":"archived"}]}',
},
},
returnData: {
param: { description: 'If true, return the deleted rows. If false, return true on success.' },
},
dryRun: {
param: {
description: 'If true, preview which rows would be deleted without actually deleting them.',
},
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const clearDataTableRowsFieldDocs = {
deletedCount: { description: 'The number of rows that were deleted' },
} as const satisfies Record<string, ZodOpenAPIMetadata>;

View File

@ -426,6 +426,7 @@ export { TestOidcConfigResponseDto } from './oidc/test-oidc-config-response.dto'
export { CreateDataTableDto } from './data-table/create-data-table.dto';
export { UpdateDataTableDto } from './data-table/update-data-table.dto';
export {
ClearDataTableRowsResponsePublicDto,
CreateDataTableColumnPublicDto,
CreateDataTablePublicDto,
CreateDataTableRowsPublicDto,
@ -434,9 +435,13 @@ export {
DataTableListPublicDto,
DataTablePublicDto,
DataTableRowListPublicDto,
DeleteDataTableRowsPublicQueryDto,
DeleteDataTableRowsResponsePublicDto,
InsertDataTableRowsResponsePublicDto,
UpdateDataTableColumnPublicDto,
UpdateDataTablePublicDto,
UpdateDataTableRowPublicDto,
UpdateDataTableRowResponsePublicDto,
UpsertDataTableRowPublicDto,
UpsertDataTableRowResponsePublicDto,
dataTablePublicSchema,

View File

@ -137,7 +137,6 @@ export default defineConfig(
// `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler
// must fail CI. Entries are removed as each handler becomes a controller.
files: [
'./src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts',
'./src/public-api/v1/handlers/ldap/ldap.handler.ts',
'./src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts',
'./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts',

View File

@ -130,7 +130,6 @@ export default defineConfig({
// `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler
// must fail CI. Entries are removed as each handler becomes a controller.
files: [
'./src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts',
'./src/public-api/v1/handlers/evaluations/evaluations.handler.ts',
'./src/public-api/v1/handlers/ldap/ldap.handler.ts',
'./src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts',

View File

@ -1,5 +1,4 @@
import type {
UpdateDataTableRowDto,
PublicCreateDestination,
UpdateOtelSettingsDto,
TestOtelTraceDto,
@ -116,27 +115,6 @@ export interface IJsonSchema {
required: string[];
}
// ----------------------------------
// /data-tables
// ----------------------------------
export declare namespace DataTableRequest {
type UpdateRows = AuthenticatedRequest<{ dataTableId: string }, {}, UpdateDataTableRowDto, {}>;
type Clear = AuthenticatedRequest<{ dataTableId: string }, {}, {}, {}>;
type DeleteRows = AuthenticatedRequest<
{ dataTableId: string },
{},
{},
{
filter?: string;
returnData?: string | boolean;
dryRun?: string | boolean;
}
>;
}
// ----------------------------------
// /community-packages
// ----------------------------------

View File

@ -1,4 +1,5 @@
import {
ClearDataTableRowsResponsePublicDto,
columnIdParamSchema,
CreateDataTableColumnPublicDto,
CreateDataTablePublicDto,
@ -8,11 +9,15 @@ import {
DataTableListPublicDto,
DataTablePublicDto,
DataTableRowListPublicDto,
DeleteDataTableRowsPublicQueryDto,
DeleteDataTableRowsResponsePublicDto,
InsertDataTableRowsResponsePublicDto,
PublicApiListDataTableQueryDto,
PublicApiListDataTableRowsQueryDto,
UpdateDataTableColumnPublicDto,
UpdateDataTablePublicDto,
UpdateDataTableRowPublicDto,
UpdateDataTableRowResponsePublicDto,
UpsertDataTableRowPublicDto,
UpsertDataTableRowResponsePublicDto,
dataTableIdParamSchema,
@ -147,6 +152,15 @@ const isRowWithState = (
row: DataTableRowReturn | DataTableRowReturnWithState,
): row is DataTableRowReturnWithState => Object.hasOwn(row, 'dryRunState');
const toMutateRowsResponsePublic = (
result: boolean | DataTableRowReturn[] | DataTableRowReturnWithState[],
) =>
typeof result === 'boolean'
? result
: result.map((row) =>
isRowWithState(row) ? toDataTableRowWithStatePublic(row) : toDataTableRowPublic(row),
);
@PublicApiController('/data-tables')
export class DataTablesPublicController {
constructor(
@ -505,11 +519,103 @@ export class DataTablesPublicController {
dryRun,
);
return typeof result === 'boolean'
? result
: result.map((row) =>
isRowWithState(row) ? toDataTableRowWithStatePublic(row) : toDataTableRowPublic(row),
);
return toMutateRowsResponsePublic(result);
} catch (error) {
return handleError(error);
}
}
@Patch('/:dataTableId/rows/update')
@ApiKeyScope('dataTableRow:update')
@ProjectScope('dataTable:writeRow')
@ApiSummary('Update rows in a data table')
@ApiDescription('Update rows matching filter conditions in a data table.')
@ApiTags(tags)
@ApiResponse(200, UpdateDataTableRowResponsePublicDto)
@ApiErrorResponse(404)
async updateDataTableRows(
req: AuthenticatedRequest,
_res: Response,
@Param('dataTableId', dataTableIdParamSchema) dataTableId: string,
@Body body: UpdateDataTableRowPublicDto,
) {
const { filter, data, returnData, dryRun } = body;
try {
await assertRowReadAccessIfReturningRows(req.user, dataTableId, { dryRun, returnData });
const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId);
const result = await this.dataTableService.updateRows(
dataTableId,
projectId,
{ filter, data },
returnData,
dryRun,
);
return toMutateRowsResponsePublic(result);
} catch (error) {
return handleError(error);
}
}
@Delete('/:dataTableId/rows/clear')
@ApiKeyScope('dataTableRow:delete')
@ProjectScope('dataTable:writeRow')
@ApiSummary('Clear all rows from a data table')
@ApiDescription(
'Permanently delete all rows from a data table. The table structure is retained. This ' +
'action cannot be undone.',
)
@ApiTags(tags)
@ApiResponse(200, ClearDataTableRowsResponsePublicDto)
@ApiErrorResponse(404)
async clearDataTableRows(
_req: AuthenticatedRequest,
_res: Response,
@Param('dataTableId', dataTableIdParamSchema) dataTableId: string,
): Promise<ClearDataTableRowsResponsePublicDto> {
try {
const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId);
return await this.dataTableService.clearRows(dataTableId, projectId);
} catch (error) {
return handleError(error);
}
}
@Delete('/:dataTableId/rows/delete')
@ApiKeyScope('dataTableRow:delete')
@ProjectScope('dataTable:writeRow')
@ApiSummary('Delete rows from a data table')
@ApiDescription(
'Delete rows matching filter conditions from a data table. Filter is required to prevent ' +
'accidental deletion of all data.',
)
@ApiTags(tags)
@ApiResponse(200, DeleteDataTableRowsResponsePublicDto)
@ApiErrorResponse(404)
async deleteDataTableRows(
req: AuthenticatedRequest,
_res: Response,
@Param('dataTableId', dataTableIdParamSchema) dataTableId: string,
@Query query: DeleteDataTableRowsPublicQueryDto,
) {
const { filter, returnData, dryRun } = query;
try {
await assertRowReadAccessIfReturningRows(req.user, dataTableId, { dryRun, returnData });
const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId);
const result = await this.dataTableService.deleteRows(
dataTableId,
projectId,
{ filter },
returnData,
dryRun,
);
return toMutateRowsResponsePublic(result);
} catch (error) {
return handleError(error);
}

View File

@ -1,392 +0,0 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { Container } from '@n8n/di';
import { GLOBAL_MEMBER_SCOPES, type Scope } from '@n8n/permissions';
import type { Response } from 'express';
import type { Mocked } from 'vitest';
import { BadRequestError, NotFoundError } from '@n8n/errors';
import { DataTableAggregateService } from '@/modules/data-table/data-table-aggregate.service';
import { DataTableService } from '@/modules/data-table/data-table.service';
import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error';
import type { DataTableRequest } from '@/public-api/types';
import * as middlewares from '@/public-api/v1/shared/middlewares/global.middleware';
// Mock middleware before requiring handler
const mockMiddleware = vi.fn(async (_req, _res, next) => next()) as any;
vi.spyOn(middlewares, 'publicApiScope').mockReturnValue(mockMiddleware);
vi.spyOn(middlewares, 'projectScope').mockReturnValue(mockMiddleware);
// Loaded after the middleware spies above are installed; typed loosely so the
// suite can invoke individual route entries by index.
let handler: Record<string, Array<(...args: unknown[]) => unknown>>;
beforeAll(async () => {
handler = (await import('../data-tables.rows.handler.js')) as unknown as typeof handler;
});
describe('DataTable Handler', () => {
let mockDataTableService: Mocked<DataTableService>;
let mockDataTableAggregateService: Mocked<DataTableAggregateService>;
let mockResponse: Partial<Response>;
const projectId = 'test-project-id';
const dataTableId = 'test-data-table-id';
const userId = 'test-user-id';
const makeUser = (scopeSlugs: Scope[] = GLOBAL_MEMBER_SCOPES) => ({
id: userId,
role: { slug: 'global:member', scopes: scopeSlugs.map((slug) => ({ slug })) },
});
beforeEach(() => {
mockDataTableService = mockInstance(DataTableService);
mockDataTableAggregateService = mockInstance(DataTableAggregateService);
vi.spyOn(Container, 'get').mockImplementation((serviceClass) => {
if (serviceClass === DataTableService) {
return mockDataTableService;
}
if (serviceClass === DataTableAggregateService) {
return mockDataTableAggregateService;
}
return {};
});
mockDataTableService.getProjectIdForDataTable.mockResolvedValue(projectId);
mockDataTableService.getCachedSizeBytesByIds.mockResolvedValue(new Map());
mockResponse = {
json: vi.fn().mockReturnThis(),
status: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis(),
};
});
afterEach(() => {
vi.clearAllMocks();
});
describe('updateDataTableRows', () => {
it('should update rows and return true when returnData is false', async () => {
// Arrange
const req = {
params: { dataTableId },
body: {
filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] },
data: { status: 'updated' },
returnData: false,
dryRun: false,
},
user: { id: userId },
} as unknown as DataTableRequest.UpdateRows;
mockDataTableService.updateRows.mockResolvedValue(true);
// Act
await handler.updateDataTableRows[2](req, mockResponse as Response);
// Assert
expect(mockDataTableService.updateRows).toHaveBeenCalledWith(
dataTableId,
projectId,
{
filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] },
data: { status: 'updated' },
},
false,
false,
);
expect(mockResponse.json).toHaveBeenCalledWith(true);
});
it('should update rows and return updated rows when returnData is true', async () => {
// Arrange
const req = {
params: { dataTableId },
body: {
filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] },
data: { status: 'updated' },
returnData: true,
dryRun: false,
},
user: makeUser(['dataTable:readRow']),
} as unknown as DataTableRequest.UpdateRows;
const mockRow = { id: 1, status: 'updated', createdAt: new Date(), updatedAt: new Date() };
mockDataTableService.updateRows.mockResolvedValue([mockRow] as any);
// Act
await handler.updateDataTableRows[2](req, mockResponse as Response);
// Assert
expect(mockResponse.json).toHaveBeenCalledWith([mockRow]);
});
it('should support dry run mode', async () => {
// Arrange
const req = {
params: { dataTableId },
body: {
filter: {
type: 'and',
filters: [{ columnName: 'id', condition: 'eq', value: 1 }],
},
data: { status: 'test' },
returnData: true,
dryRun: true,
},
user: makeUser(['dataTable:readRow']),
} as unknown as DataTableRequest.UpdateRows;
const mockDryRunResult = [
{ id: 1, status: 'old', dryRunState: 'before' },
{ id: 1, status: 'test', dryRunState: 'after' },
];
mockDataTableService.updateRows.mockResolvedValue(mockDryRunResult as any);
// Act
await handler.updateDataTableRows[2](req, mockResponse as Response);
// Assert
expect(mockDataTableService.updateRows).toHaveBeenCalledWith(
dataTableId,
projectId,
expect.any(Object),
true,
true,
);
});
});
describe('deleteDataTableRows', () => {
it('should delete rows and return true when returnData is false', async () => {
// Arrange
const filterStr = JSON.stringify({
type: 'and',
filters: [{ columnName: 'status', condition: 'eq', value: 'archived' }],
});
const req = {
params: { dataTableId },
query: {
filter: filterStr,
returnData: 'false',
dryRun: 'false',
},
user: { id: userId },
} as unknown as DataTableRequest.DeleteRows;
mockDataTableService.deleteRows.mockResolvedValue(true);
// Act
await handler.deleteDataTableRows[2](req, mockResponse as Response);
// Assert
expect(mockDataTableService.deleteRows).toHaveBeenCalledWith(
dataTableId,
projectId,
{
filter: JSON.parse(filterStr),
},
false,
false,
);
expect(mockResponse.json).toHaveBeenCalledWith(true);
});
it('should delete rows and return deleted rows when returnData is true', async () => {
// Arrange
const filterStr = JSON.stringify({
type: 'and',
filters: [{ columnName: 'id', condition: 'eq', value: 1 }],
});
const req = {
params: { dataTableId },
query: {
filter: filterStr,
returnData: 'true',
dryRun: 'false',
},
user: makeUser(['dataTable:readRow']),
} as unknown as DataTableRequest.DeleteRows;
const mockRow = { id: 1, name: 'Deleted', createdAt: new Date(), updatedAt: new Date() };
mockDataTableService.deleteRows.mockResolvedValue([mockRow] as any);
// Act
await handler.deleteDataTableRows[2](req, mockResponse as Response);
// Assert
expect(mockResponse.json).toHaveBeenCalledWith([mockRow]);
});
it('should throw BadRequestError when filter is missing', async () => {
// Arrange
const req = {
params: { dataTableId },
query: {},
user: { id: userId },
} as unknown as DataTableRequest.DeleteRows;
// Act
const handlerFn = handler.deleteDataTableRows[2];
let caught: unknown;
try {
await handlerFn(req, mockResponse as Response);
} catch (error) {
caught = error;
}
// Assert
expect(caught).toBeInstanceOf(BadRequestError);
expect(caught).toMatchObject({
message: 'Required',
httpStatusCode: 400,
});
});
it('should support dry run mode', async () => {
// Arrange
const filterStr = JSON.stringify({
type: 'and',
filters: [{ columnName: 'status', condition: 'eq', value: 'test' }],
});
const req = {
params: { dataTableId },
query: {
filter: filterStr,
returnData: 'true',
dryRun: 'true',
},
user: makeUser(['dataTable:readRow']),
} as unknown as DataTableRequest.DeleteRows;
const mockRows = [{ id: 1, status: 'test', createdAt: new Date(), updatedAt: new Date() }];
mockDataTableService.deleteRows.mockResolvedValue(mockRows as any);
// Act
await handler.deleteDataTableRows[2](req, mockResponse as Response);
// Assert
expect(mockDataTableService.deleteRows).toHaveBeenCalledWith(
dataTableId,
projectId,
expect.objectContaining({ filter: expect.any(Object) }),
true,
true,
);
});
});
describe('Security - Cross-Project Access', () => {
const otherUserDataTableId = 'other-user-data-table-id';
it('should throw NotFoundError when trying to update rows in another users data table', async () => {
// Arrange
const req = {
params: { dataTableId: otherUserDataTableId },
body: {
filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] },
data: { status: 'hacked' },
returnData: false,
dryRun: false,
},
user: { id: userId },
} as unknown as DataTableRequest.UpdateRows;
mockDataTableService.updateRows.mockRejectedValue(
new DataTableNotFoundError(otherUserDataTableId),
);
// Act
const handlerFn = handler.updateDataTableRows[2];
let caught: unknown;
try {
await handlerFn(req, mockResponse as Response);
} catch (error) {
caught = error;
}
// Assert
expect(caught).toBeInstanceOf(NotFoundError);
expect(caught).toMatchObject({
message: expect.stringContaining(otherUserDataTableId),
httpStatusCode: 404,
});
});
it('should throw NotFoundError when trying to delete rows from another users data table', async () => {
// Arrange
const filterStr = JSON.stringify({
type: 'and',
filters: [{ columnName: 'id', condition: 'eq', value: 1 }],
});
const req = {
params: { dataTableId: otherUserDataTableId },
query: {
filter: filterStr,
returnData: 'false',
dryRun: 'false',
},
user: { id: userId },
} as unknown as DataTableRequest.DeleteRows;
mockDataTableService.deleteRows.mockRejectedValue(
new DataTableNotFoundError(otherUserDataTableId),
);
// Act
const handlerFn = handler.deleteDataTableRows[2];
let caught: unknown;
try {
await handlerFn(req, mockResponse as Response);
} catch (error) {
caught = error;
}
// Assert
expect(caught).toBeInstanceOf(NotFoundError);
expect(caught).toMatchObject({
message: expect.stringContaining(otherUserDataTableId),
httpStatusCode: 404,
});
});
it('should not leak information about data table existence in error messages', async () => {
// Arrange
const nonExistentDataTableId = 'non-existent-table-id';
const filterStr = JSON.stringify({
type: 'and',
filters: [{ columnName: 'id', condition: 'eq', value: 1 }],
});
const req = {
params: { dataTableId: nonExistentDataTableId },
query: { filter: filterStr },
user: { id: userId },
} as unknown as DataTableRequest.DeleteRows;
mockDataTableService.getProjectIdForDataTable.mockRejectedValue(
new DataTableNotFoundError(nonExistentDataTableId),
);
// Act
const handlerFn = handler.deleteDataTableRows[2];
let caught: unknown;
try {
await handlerFn(req, mockResponse as Response);
} catch (error) {
caught = error;
}
// Assert
// The error message should be the same whether:
// 1. The table doesn't exist at all
// 2. The table exists but belongs to another user's project
// This prevents information leakage
expect(caught).toBeInstanceOf(NotFoundError);
expect(caught).toMatchObject({
message: expect.stringContaining(nonExistentDataTableId),
httpStatusCode: 404,
});
});
});
});

View File

@ -1,21 +0,0 @@
import { stringifyQuery } from '../data-tables.utils';
describe('stringifyQuery', () => {
it('converts values to strings', () => {
expect(stringifyQuery({ limit: 10, sortBy: 'id', includeCount: true })).toEqual({
limit: '10',
sortBy: 'id',
includeCount: 'true',
});
});
it('drops undefined and null values', () => {
expect(stringifyQuery({ limit: 10, cursor: undefined, filter: null })).toEqual({
limit: '10',
});
});
it('returns an empty object for an empty query', () => {
expect(stringifyQuery({})).toEqual({});
});
});

View File

@ -1,111 +0,0 @@
import { UpdateDataTableRowDto, DeleteDataTableRowsDto } from '@n8n/api-types';
import { Container } from '@n8n/di';
import { BadRequestError, NotFoundError } from '@n8n/errors';
import { assertRowReadAccessIfReturningRows } from '@/modules/data-table/data-table-permissions';
import { DataTableService } from '@/modules/data-table/data-table.service';
import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error';
import { DataTableValidationError } from '@/modules/data-table/errors/data-table-validation.error';
import type { DataTableRequest } from '../../../types';
import type { PublicAPIEndpoint } from '../../shared/handler.types';
import { publicApiScope, projectScope } from '../../shared/middlewares/global.middleware';
import { stringifyQuery } from './data-tables.utils';
const handleError = (error: unknown) => {
if (error instanceof DataTableNotFoundError) {
throw new NotFoundError(error.message);
}
if (error instanceof DataTableValidationError) {
throw new BadRequestError(error.message);
}
throw error;
};
type DataTableRowsHandlers = {
updateDataTableRows: PublicAPIEndpoint<DataTableRequest.UpdateRows>;
clearDataTableRows: PublicAPIEndpoint<DataTableRequest.Clear>;
deleteDataTableRows: PublicAPIEndpoint<DataTableRequest.DeleteRows>;
};
const dataTableRowsHandlers: DataTableRowsHandlers = {
updateDataTableRows: [
publicApiScope('dataTableRow:update'),
projectScope('dataTable:writeRow', 'dataTable'),
async (req, res) => {
try {
const { dataTableId } = req.params;
const payload = UpdateDataTableRowDto.safeParse(req.body);
if (!payload.success) {
throw new BadRequestError(payload.error.errors[0]?.message || 'Invalid request body');
}
const projectId =
await Container.get(DataTableService).getProjectIdForDataTable(dataTableId);
const service = Container.get(DataTableService);
const { filter, data, returnData = false, dryRun = false } = payload.data;
const params = { filter, data };
await assertRowReadAccessIfReturningRows(req.user, dataTableId, { dryRun, returnData });
const result = await service.updateRows(dataTableId, projectId, params, returnData, dryRun);
return res.json(result);
} catch (error) {
return handleError(error);
}
},
],
clearDataTableRows: [
publicApiScope('dataTableRow:delete'),
projectScope('dataTable:writeRow', 'dataTable'),
async (req, res) => {
try {
const { dataTableId } = req.params;
const projectId =
await Container.get(DataTableService).getProjectIdForDataTable(dataTableId);
const result = await Container.get(DataTableService).clearRows(dataTableId, projectId);
return res.json(result);
} catch (error) {
return handleError(error);
}
},
],
deleteDataTableRows: [
publicApiScope('dataTableRow:delete'),
projectScope('dataTable:writeRow', 'dataTable'),
async (req, res) => {
try {
const { dataTableId } = req.params;
const payload = DeleteDataTableRowsDto.safeParse(stringifyQuery(req.query));
if (!payload.success) {
throw new BadRequestError(payload.error.errors[0]?.message || 'Invalid query parameters');
}
const projectId =
await Container.get(DataTableService).getProjectIdForDataTable(dataTableId);
const service = Container.get(DataTableService);
const { filter, returnData = false, dryRun = false } = payload.data;
const params = { filter };
await assertRowReadAccessIfReturningRows(req.user, dataTableId, { dryRun, returnData });
const result = await service.deleteRows(dataTableId, projectId, params, returnData, dryRun);
return res.json(result);
} catch (error) {
return handleError(error);
}
},
],
};
export = dataTableRowsHandlers;

View File

@ -1,15 +0,0 @@
/**
* Convert all query parameter values to strings for DTO validation.
* Express/Supertest may parse some values as numbers/booleans.
*/
export const stringifyQuery = (
query: Record<string, unknown>,
): Record<string, string | undefined> => {
const result: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null) {
result[key] = String(value);
}
}
return result;
};

View File

@ -0,0 +1,37 @@
operationId: clearDataTableRows
tags:
- DataTable
summary: Clear all rows from a data table
description: Permanently delete all rows from a data table. The table structure is retained. This action cannot be undone.
x-required-scope: dataTableRow:delete
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
format: nanoid
pattern: ^[A-Za-z0-9]{16}$
required: true
description: The ID of the data table
name: dataTableId
in: path
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
type: object
properties:
deletedCount:
type: integer
description: The number of rows that were deleted
required:
- deletedCount
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml

View File

@ -1,28 +0,0 @@
delete:
x-eov-operation-id: clearDataTableRows
x-required-scope: dataTableRow:delete
x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler
tags:
- DataTable
summary: Clear all rows from a data table
description: Permanently deletes all rows from a data table. The table structure will be retained. This action cannot be undone.
operationId: clear-data-table-rows
parameters:
- $ref: '../schemas/parameters/dataTableId.yml'
responses:
'200':
description: Rows cleared successfully
content:
application/json:
schema:
type: object
properties:
deletedCount:
type: integer
description: The number of rows that were deleted
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
security:
- ApiKeyAuth: []

View File

@ -1,52 +0,0 @@
delete:
x-eov-operation-id: deleteDataTableRows
x-required-scope: dataTableRow:delete
x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler
tags:
- DataTable
summary: Delete rows from a data table
description: Delete rows matching filter conditions from a data table. Filter is required to prevent accidental deletion of all data.
operationId: delete-data-table-rows
parameters:
- $ref: '../schemas/parameters/dataTableId.yml'
- name: filter
in: query
required: true
description: JSON string of filter conditions. Required to prevent accidental deletion of all data.
schema:
type: string
format: jsonString
example: '{"type":"and","filters":[{"columnName":"status","condition":"eq","value":"archived"}]}'
- name: returnData
in: query
description: If true, return the deleted rows; if false, return true on success
schema:
type: boolean
default: false
- name: dryRun
in: query
description: If true, preview which rows would be deleted without actually deleting them
schema:
type: boolean
default: false
responses:
'200':
description: Rows deleted successfully
content:
application/json:
schema:
oneOf:
- type: boolean
description: True when returnData is false
- type: array
items:
$ref: '../schemas/dataTableRow.yml'
description: Deleted rows when returnData is true
'400':
$ref: '../../../../shared/spec/responses/badRequest.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
security:
- ApiKeyAuth: []

View File

@ -1,50 +0,0 @@
patch:
x-eov-operation-id: updateDataTableRows
x-required-scope: dataTableRow:update
x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler
tags:
- DataTable
summary: Update rows in a data table
description: Update rows matching filter conditions in a data table.
operationId: update-data-table-rows
parameters:
- $ref: '../schemas/parameters/dataTableId.yml'
requestBody:
required: true
content:
application/json:
schema:
$ref: '../schemas/updateRowsRequest.yml'
example:
filter:
type: 'and'
filters:
- columnName: 'status'
condition: 'eq'
value: 'pending'
data:
status: 'completed'
updatedBy: 'admin'
returnData: false
dryRun: false
responses:
'200':
description: Rows updated successfully
content:
application/json:
schema:
oneOf:
- type: boolean
description: True when returnData is false
- type: array
items:
$ref: '../schemas/dataTableRow.yml'
description: Updated rows when returnData is true
'400':
$ref: '../../../../shared/spec/responses/badRequest.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
security:
- ApiKeyAuth: []

View File

@ -0,0 +1,114 @@
operationId: deleteDataTableRows
tags:
- DataTable
summary: Delete rows from a data table
description: Delete rows matching filter conditions from a data table. Filter is required to prevent accidental deletion of all data.
x-required-scope: dataTableRow:delete
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
format: nanoid
pattern: ^[A-Za-z0-9]{16}$
required: true
description: The ID of the data table
name: dataTableId
in: path
- schema:
type: string
format: jsonString
required: true
description: JSON string of filter conditions. Required to prevent accidental deletion of all data.
example: '{"type":"and","filters":[{"columnName":"status","condition":"eq","value":"archived"}]}'
name: filter
in: query
- schema:
type: string
enum:
- 'true'
- 'false'
default: 'false'
required: false
description: If true, return the deleted rows. If false, return true on success.
name: returnData
in: query
- schema:
type: string
enum:
- 'true'
- 'false'
default: 'false'
required: false
description: If true, preview which rows would be deleted without actually deleting them.
name: dryRun
in: query
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
anyOf:
- type: boolean
description: Returned when returnData is false and dryRun is false
- type: array
items:
type: object
properties:
id:
type: integer
nullable: true
description: The row ID (auto-generated)
createdAt:
type: string
nullable: true
format: date-time
description: The date and time the row was created
updatedAt:
type: string
nullable: true
format: date-time
description: The date and time the row was last updated
dryRunState:
type: string
enum:
- before
- after
description: Whether this entry shows the row state before or after the change
required:
- id
- createdAt
- updatedAt
- dryRunState
description: 'Returned when dryRun is true: one entry per row per before/after state'
- type: array
items:
type: object
properties:
id:
type: integer
description: The row ID (auto-generated)
createdAt:
type: string
format: date-time
description: The date and time the row was created
updatedAt:
type: string
format: date-time
description: The date and time the row was last updated
required:
- id
- createdAt
- updatedAt
description: A data table row with system columns (id, createdAt, updatedAt) and user-defined columns
description: Returned when returnData is true and dryRun is false
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml

View File

@ -0,0 +1,181 @@
operationId: updateDataTableRows
tags:
- DataTable
summary: Update rows in a data table
description: Update rows matching filter conditions in a data table.
x-required-scope: dataTableRow:update
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
format: nanoid
pattern: ^[A-Za-z0-9]{16}$
required: true
description: The ID of the data table
name: dataTableId
in: path
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
filter:
type: object
properties:
type:
anyOf:
- type: string
enum:
- and
- type: string
enum:
- or
default: and
filters:
type: array
items:
type: object
properties:
columnName:
type: string
minLength: 1
maxLength: 63
pattern: ^[a-zA-Z][a-zA-Z0-9_]*$
condition:
anyOf:
- type: string
enum:
- eq
- type: string
enum:
- neq
- type: string
enum:
- like
- type: string
enum:
- ilike
- type: string
enum:
- gt
- type: string
enum:
- gte
- type: string
enum:
- lt
- type: string
enum:
- lte
value:
anyOf:
- type: string
- type: number
- type: boolean
- type: string
- {}
- {}
required:
- columnName
- condition
- value
minItems: 1
required:
- filters
description: Filter conditions to match rows for update.
data:
type: object
additionalProperties:
anyOf:
- type: string
- type: number
- type: boolean
- {}
- type: string
- {}
description: Column values to update
returnData:
type: boolean
default: false
description: Set to true to return the updated row(s). Set to false to return true on success.
dryRun:
type: boolean
default: false
description: Set to true to preview the change without saving it.
required:
- filter
- data
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
anyOf:
- type: boolean
description: Returned when returnData is false and dryRun is false
- type: array
items:
type: object
properties:
id:
type: integer
nullable: true
description: The row ID (auto-generated)
createdAt:
type: string
nullable: true
format: date-time
description: The date and time the row was created
updatedAt:
type: string
nullable: true
format: date-time
description: The date and time the row was last updated
dryRunState:
type: string
enum:
- before
- after
description: Whether this entry shows the row state before or after the change
required:
- id
- createdAt
- updatedAt
- dryRunState
description: 'Returned when dryRun is true: one entry per row per before/after state'
- type: array
items:
type: object
properties:
id:
type: integer
description: The row ID (auto-generated)
createdAt:
type: string
format: date-time
description: The date and time the row was created
updatedAt:
type: string
format: date-time
description: The date and time the row was last updated
required:
- id
- createdAt
- updatedAt
description: A data table row with system columns (id, createdAt, updatedAt) and user-defined columns
description: Returned when returnData is true and dryRun is false
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml

View File

@ -71,12 +71,6 @@ requestBody:
- type: string
enum:
- lte
- type: string
enum:
- isEmpty
- type: string
enum:
- isNotEmpty
value:
anyOf:
- type: string
@ -89,6 +83,7 @@ requestBody:
- columnName
- condition
- value
minItems: 1
required:
- filters
description: Filter conditions to match an existing row. If no row matches, n8n inserts a new row.
@ -106,7 +101,7 @@ requestBody:
returnData:
type: boolean
default: false
description: Set to true to return the upserted row(s) matched by the filter. Set to false to return true on success.
description: 'Set to true to return the upserted row(s): the matching rows if updated, or the new row if one was inserted. Set to false to return true on success.'
dryRun:
type: boolean
default: false

View File

@ -1,15 +0,0 @@
type: object
properties:
id:
type: integer
description: The row ID (auto-generated)
createdAt:
type: string
format: date-time
description: The date and time the row was created
updatedAt:
type: string
format: date-time
description: The date and time the row was last updated
additionalProperties: true
description: A data table row with system columns (id, createdAt, updatedAt) and user-defined columns

View File

@ -1,43 +0,0 @@
type: object
properties:
filter:
type: object
properties:
type:
type: string
enum: [and, or]
default: and
filters:
type: array
minItems: 1
items:
type: object
properties:
columnName:
type: string
condition:
type: string
enum: [eq, neq, like, ilike, gt, gte, lt, lte]
value: {}
required:
- columnName
- condition
- value
required:
- filters
description: Filter conditions to match rows for update
data:
type: object
additionalProperties: true
description: Column values to update
returnData:
type: boolean
default: false
description: If true, return the updated rows; if false, return true on success
dryRun:
type: boolean
default: false
description: If true, preview changes without persisting them
required:
- filter
- data

View File

@ -92,6 +92,15 @@ paths:
/data-tables/{dataTableId}/rows/upsert:
post:
$ref: ./handlers/data-tables/spec/paths/upsertDataTableRow.generated.yml
/data-tables/{dataTableId}/rows/update:
patch:
$ref: ./handlers/data-tables/spec/paths/updateDataTableRows.generated.yml
/data-tables/{dataTableId}/rows/clear:
delete:
$ref: ./handlers/data-tables/spec/paths/clearDataTableRows.generated.yml
/data-tables/{dataTableId}/rows/delete:
delete:
$ref: ./handlers/data-tables/spec/paths/deleteDataTableRows.generated.yml
/discover:
get:
$ref: ./handlers/discover/spec/paths/getDiscover.generated.yml

View File

@ -85,12 +85,6 @@ paths:
$ref: './handlers/ldap/spec/paths/settings.ldap.sync.yml'
/workflows/{id}/{versionId}:
$ref: './handlers/workflows/spec/paths/workflows.id.versionId.yml'
/data-tables/{dataTableId}/rows/update:
$ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.update.yml'
/data-tables/{dataTableId}/rows/clear:
$ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.clear.yml'
/data-tables/{dataTableId}/rows/delete:
$ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.delete.yml'
/n8n-packages/export:
$ref: './handlers/n8n-packages/spec/paths/n8n-packages.export.yml'
/n8n-packages/import:

View File

@ -1257,6 +1257,73 @@ describe('PATCH /data-tables/:dataTableId/rows/update', () => {
testWithAPIKey('patch', '/data-tables/123/rows/update', 'abcXYZ'),
);
test('should reject updating rows without dataTableRow:update', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [{ name: 'status', type: 'string' }],
data: [{ status: 'pending' }],
});
const unscopedAgent = await createUnscopedAgent();
const response = await unscopedAgent.patch(`/data-tables/${dataTable.id}/rows/update`).send({
filter: {
type: 'and',
filters: [{ columnName: 'status', condition: 'eq', value: 'pending' }],
},
data: { status: 'completed' },
});
expect(response.statusCode).toBe(403);
expect(response.body).toHaveProperty('message', 'Forbidden');
});
test('should reject a filter entry missing condition', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [{ name: 'status', type: 'string' }],
data: [{ status: 'pending' }],
});
const response = await authOwnerAgent.patch(`/data-tables/${dataTable.id}/rows/update`).send({
filter: {
type: 'and',
filters: [{ columnName: 'status', value: 'pending' }],
},
data: { status: 'completed' },
});
expect(response.statusCode).toBe(400);
});
test('should reject an empty filters array', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [{ name: 'status', type: 'string' }],
data: [{ status: 'pending' }],
});
const response = await authOwnerAgent.patch(`/data-tables/${dataTable.id}/rows/update`).send({
filter: { type: 'and', filters: [] },
data: { status: 'completed' },
});
expect(response.statusCode).toBe(400);
});
test('should reject isEmpty and isNotEmpty conditions', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [{ name: 'status', type: 'string' }],
data: [{ status: 'pending' }],
});
const response = await authOwnerAgent.patch(`/data-tables/${dataTable.id}/rows/update`).send({
filter: {
type: 'and',
filters: [{ columnName: 'status', condition: 'isEmpty', value: null }],
},
data: { status: 'completed' },
});
expect(response.statusCode).toBe(400);
});
test('should update rows with returnData false', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [
@ -1392,6 +1459,37 @@ describe('POST /data-tables/:dataTableId/rows/upsert', () => {
expect(response.statusCode).toBe(400);
});
test('should reject an empty filters array', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [{ name: 'email', type: 'string' }],
data: [{ email: 'test@example.com' }],
});
const response = await authOwnerAgent.post(`/data-tables/${dataTable.id}/rows/upsert`).send({
filter: { type: 'and', filters: [] },
data: { email: 'updated@example.com' },
});
expect(response.statusCode).toBe(400);
});
test('should reject isEmpty and isNotEmpty conditions', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [{ name: 'email', type: 'string' }],
data: [{ email: 'test@example.com' }],
});
const response = await authOwnerAgent.post(`/data-tables/${dataTable.id}/rows/upsert`).send({
filter: {
type: 'and',
filters: [{ columnName: 'email', condition: 'isNotEmpty', value: null }],
},
data: { email: 'updated@example.com' },
});
expect(response.statusCode).toBe(400);
});
test('should upsert row with returnData false', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [
@ -1489,6 +1587,85 @@ describe('DELETE /data-tables/:dataTableId/rows/delete', () => {
testWithAPIKey('delete', '/data-tables/123/rows/delete', 'abcXYZ'),
);
test('should reject deleting rows without dataTableRow:delete', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [{ name: 'status', type: 'string' }],
data: [{ status: 'old' }],
});
const unscopedAgent = await createUnscopedAgent();
const filter = JSON.stringify({
type: 'and',
filters: [{ columnName: 'status', condition: 'eq', value: 'old' }],
});
const response = await unscopedAgent
.delete(`/data-tables/${dataTable.id}/rows/delete`)
.query({ filter });
expect(response.statusCode).toBe(403);
expect(response.body).toHaveProperty('message', 'Forbidden');
});
test('should default a filter entry missing condition to eq', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [{ name: 'status', type: 'string' }],
data: [{ status: 'old' }, { status: 'active' }],
});
const filter = JSON.stringify({
type: 'and',
filters: [{ columnName: 'status', value: 'old' }],
});
const response = await authOwnerAgent
.delete(`/data-tables/${dataTable.id}/rows/delete`)
.query({ filter, returnData: 'true' });
expect(response.statusCode).toBe(200);
expect(response.body).toHaveLength(1);
expect(response.body[0]).toHaveProperty('status', 'old');
});
test('should accept isEmpty condition', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [{ name: 'status', type: 'string' }],
data: [{ status: null }, { status: 'archived' }],
});
const filter = JSON.stringify({
type: 'and',
filters: [{ columnName: 'status', condition: 'isEmpty', value: null }],
});
const response = await authOwnerAgent
.delete(`/data-tables/${dataTable.id}/rows/delete`)
.query({ filter, returnData: 'true' });
expect(response.statusCode).toBe(200);
expect(response.body).toHaveLength(1);
});
test('should accept isNotEmpty condition', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [{ name: 'status', type: 'string' }],
data: [{ status: null }, { status: 'archived' }],
});
const filter = JSON.stringify({
type: 'and',
filters: [{ columnName: 'status', condition: 'isNotEmpty', value: null }],
});
const response = await authOwnerAgent
.delete(`/data-tables/${dataTable.id}/rows/delete`)
.query({ filter, returnData: 'true' });
expect(response.statusCode).toBe(200);
expect(response.body).toHaveLength(1);
expect(response.body[0]).toHaveProperty('status', 'archived');
});
test('should delete rows with returnData false', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [{ name: 'status', type: 'string' }],
@ -1584,6 +1761,95 @@ describe('DELETE /data-tables/:dataTableId/rows/delete', () => {
"request/query must have required property 'filter'",
);
});
test('should reject a non-boolean dryRun value instead of coercing it', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [{ name: 'status', type: 'string' }],
data: [{ status: 'old' }],
});
const filter = JSON.stringify({
type: 'and',
filters: [{ columnName: 'status', condition: 'eq', value: 'old' }],
});
const response = await authOwnerAgent
.delete(`/data-tables/${dataTable.id}/rows/delete`)
.query({ filter, dryRun: '1' });
expect(response.statusCode).toBe(400);
// Verify data was not actually deleted
const getResponse = await authOwnerAgent.get(`/data-tables/${dataTable.id}/rows`);
expect(getResponse.body.data).toHaveLength(1);
});
test('should reject an empty filters array with the delete-specific message', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [{ name: 'status', type: 'string' }],
data: [{ status: 'old' }],
});
const filter = JSON.stringify({ type: 'and', filters: [] });
const response = await authOwnerAgent
.delete(`/data-tables/${dataTable.id}/rows/delete`)
.query({ filter });
expect(response.statusCode).toBe(400);
expect(response.body.message).toContain(
'At least one filter condition is required for delete operations',
);
});
});
describe('DELETE /data-tables/:dataTableId/rows/clear', () => {
test(
'should fail due to missing API Key',
testWithAPIKey('delete', '/data-tables/123/rows/clear', null),
);
test(
'should fail due to invalid API Key',
testWithAPIKey('delete', '/data-tables/123/rows/clear', 'abcXYZ'),
);
test('should reject clearing rows without dataTableRow:delete', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [{ name: 'status', type: 'string' }],
});
const unscopedAgent = await createUnscopedAgent();
const response = await unscopedAgent.delete(`/data-tables/${dataTable.id}/rows/clear`);
expect(response.statusCode).toBe(403);
});
test('should return 404 for non-existing data table', async () => {
const nonExistentId = 'abcd1234efgh5678'; // Valid nanoid format but doesn't exist
const response = await authOwnerAgent.delete(`/data-tables/${nonExistentId}/rows/clear`);
expect(response.statusCode).toBe(404);
expect(response.body).toHaveProperty(
'message',
`Could not find the data table: '${nonExistentId}'`,
);
});
test('should clear all rows from a data table', async () => {
const dataTable = await createDataTable(ownerPersonalProject, {
columns: [{ name: 'status', type: 'string' }],
data: [{ status: 'active' }, { status: 'archived' }, { status: 'active' }],
});
const response = await authOwnerAgent.delete(`/data-tables/${dataTable.id}/rows/clear`);
expect(response.statusCode).toBe(200);
expect(response.body).toStrictEqual({ deletedCount: 3 });
const getResponse = await authOwnerAgent.get(`/data-tables/${dataTable.id}/rows`);
expect(getResponse.body.data).toHaveLength(0);
});
});
describe('row-content authorization on write endpoints', () => {