feat(core): Refactor data table update row to use filters (no-changelog) (#19092)

This commit is contained in:
Daria
2025-09-04 18:25:29 +03:00
committed by GitHub
parent 2f2672c551
commit ed6f60f52e
18 changed files with 1421 additions and 233 deletions

View File

@@ -3,40 +3,9 @@ import { z } from 'zod';
import { Z } from 'zod-class'; import { Z } from 'zod-class';
import { dataStoreColumnNameSchema } from '../../schemas/data-store.schema'; import { dataStoreColumnNameSchema } from '../../schemas/data-store.schema';
import { dataTableFilterSchema } from '../../schemas/data-table-filter.schema';
import { paginationSchema } from '../pagination/pagination.dto'; import { paginationSchema } from '../pagination/pagination.dto';
const FilterConditionSchema = 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'),
]);
export type ListDataStoreContentFilterConditionType = z.infer<typeof FilterConditionSchema>;
const filterRecord = z.object({
columnName: dataStoreColumnNameSchema,
condition: FilterConditionSchema.default('eq'),
value: z.union([z.string(), z.number(), z.boolean(), z.date(), z.null()]),
});
const chainedFilterSchema = z.union([z.literal('and'), z.literal('or')]);
export type ListDataStoreContentFilter = z.infer<typeof filterSchema>;
// ---------------------
// Parameter Validators
// ---------------------
const filterSchema = z.object({
type: chainedFilterSchema.default('and'),
filters: z.array(filterRecord).default([]),
});
// Filter parameter validation
const filterValidator = z const filterValidator = z
.string() .string()
.optional() .optional()
@@ -45,7 +14,7 @@ const filterValidator = z
try { try {
const parsed: unknown = jsonParse(val); const parsed: unknown = jsonParse(val);
try { try {
return filterSchema.parse(parsed); return dataTableFilterSchema.parse(parsed);
} catch (e) { } catch (e) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
@@ -64,7 +33,6 @@ const filterValidator = z
} }
}); });
// SortBy parameter validation
const sortByValidator = z const sortByValidator = z
.string() .string()
.optional() .optional()

View File

@@ -5,13 +5,14 @@ import {
dataStoreColumnNameSchema, dataStoreColumnNameSchema,
dataStoreColumnValueSchema, dataStoreColumnValueSchema,
} from '../../schemas/data-store.schema'; } from '../../schemas/data-store.schema';
import { dataTableFilterSchema } from '../../schemas/data-table-filter.schema';
const updateDataStoreRowShape = { const updateFilterSchema = dataTableFilterSchema.refine((filter) => filter.filters.length > 0, {
filter: z message: 'filter must not be empty',
.record(dataStoreColumnNameSchema, dataStoreColumnValueSchema) });
.refine((obj) => Object.keys(obj).length > 0, {
message: 'filter must not be empty', const updateDataTableRowShape = {
}), filter: updateFilterSchema,
data: z data: z
.record(dataStoreColumnNameSchema, dataStoreColumnValueSchema) .record(dataStoreColumnNameSchema, dataStoreColumnValueSchema)
.refine((obj) => Object.keys(obj).length > 0, { .refine((obj) => Object.keys(obj).length > 0, {
@@ -20,4 +21,4 @@ const updateDataStoreRowShape = {
returnData: z.boolean().default(false), returnData: z.boolean().default(false),
}; };
export class UpdateDataStoreRowDto extends Z.class(updateDataStoreRowShape) {} export class UpdateDataTableRowDto extends Z.class(updateDataTableRowShape) {}

View File

@@ -85,14 +85,10 @@ export { OidcConfigDto } from './oidc/config.dto';
export { CreateDataStoreDto } from './data-store/create-data-store.dto'; export { CreateDataStoreDto } from './data-store/create-data-store.dto';
export { UpdateDataStoreDto } from './data-store/update-data-store.dto'; export { UpdateDataStoreDto } from './data-store/update-data-store.dto';
export { UpdateDataStoreRowDto } from './data-store/update-data-store-row.dto'; export { UpdateDataTableRowDto } from './data-store/update-data-store-row.dto';
export { UpsertDataStoreRowsDto } from './data-store/upsert-data-store-rows.dto'; export { UpsertDataStoreRowsDto } from './data-store/upsert-data-store-rows.dto';
export { ListDataStoreQueryDto } from './data-store/list-data-store-query.dto'; export { ListDataStoreQueryDto } from './data-store/list-data-store-query.dto';
export { ListDataStoreContentQueryDto } from './data-store/list-data-store-content-query.dto'; export { ListDataStoreContentQueryDto } from './data-store/list-data-store-content-query.dto';
export type {
ListDataStoreContentFilter,
ListDataStoreContentFilterConditionType,
} from './data-store/list-data-store-content-query.dto';
export { CreateDataStoreColumnDto } from './data-store/create-data-store-column.dto'; export { CreateDataStoreColumnDto } from './data-store/create-data-store-column.dto';
export { AddDataStoreRowsDto } from './data-store/add-data-store-rows.dto'; export { AddDataStoreRowsDto } from './data-store/add-data-store-rows.dto';
export { AddDataStoreColumnDto } from './data-store/add-data-store-column.dto'; export { AddDataStoreColumnDto } from './data-store/add-data-store-column.dto';

View File

@@ -56,3 +56,8 @@ export {
type DataStoreListOptions, type DataStoreListOptions,
dateTimeSchema, dateTimeSchema,
} from './schemas/data-store.schema'; } from './schemas/data-store.schema';
export type {
DataTableFilter,
DataTableFilterConditionType,
} from './schemas/data-table-filter.schema';

View File

@@ -0,0 +1,31 @@
import { z } from 'zod';
import { dataStoreColumnNameSchema } from './data-store.schema';
export const FilterConditionSchema = 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'),
]);
export type DataTableFilterConditionType = z.infer<typeof FilterConditionSchema>;
export const dataTableFilterRecordSchema = z.object({
columnName: dataStoreColumnNameSchema,
condition: FilterConditionSchema.default('eq'),
value: z.union([z.string(), z.number(), z.boolean(), z.date(), z.null()]),
});
export const dataTableFilterTypeSchema = z.union([z.literal('and'), z.literal('or')]);
export const dataTableFilterSchema = z.object({
type: dataTableFilterTypeSchema.default('and'),
filters: z.array(dataTableFilterRecordSchema).default([]),
});
export type DataTableFilter = z.infer<typeof dataTableFilterSchema>;

View File

@@ -3181,7 +3181,7 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
test('should not update row when data store does not exist', async () => { test('should not update row when data store does not exist', async () => {
const project = await createTeamProject('test project', owner); const project = await createTeamProject('test project', owner);
const payload = { const payload = {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { age: 31 }, data: { age: 31 },
}; };
@@ -3201,7 +3201,7 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}); });
const payload = { const payload = {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { age: 31 }, data: { age: 31 },
}; };
@@ -3247,7 +3247,7 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}); });
const payload = { const payload = {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { name: 'Alicia', age: 31, active: false, birthday: new Date('1990-01-02') }, data: { name: 'Alicia', age: 31, active: false, birthday: new Date('1990-01-02') },
}; };
@@ -3284,7 +3284,7 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}); });
const payload = { const payload = {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { age: 31 }, data: { age: 31 },
}; };
@@ -3312,7 +3312,7 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}); });
const payload = { const payload = {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { age: 31 }, data: { age: 31 },
}; };
@@ -3343,7 +3343,7 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}); });
const payload = { const payload = {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { age: 31 }, data: { age: 31 },
}; };
@@ -3377,7 +3377,7 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}); });
const payload = { const payload = {
filter: { id: 1 }, filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] },
data: { age: 31 }, data: { age: 31 },
}; };
@@ -3422,7 +3422,13 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}); });
const payload = { const payload = {
filter: { name: 'Alice', age: 30 }, filter: {
type: 'and',
filters: [
{ columnName: 'name', condition: 'eq', value: 'Alice' },
{ columnName: 'age', condition: 'eq', value: 30 },
],
},
data: { department: 'Management' }, data: { department: 'Management' },
}; };
@@ -3470,7 +3476,7 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}); });
const payload = { const payload = {
filter: { name: 'Charlie' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Charlie' }] },
data: { age: 25 }, data: { age: 25 },
}; };
@@ -3516,7 +3522,7 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}); });
const payload = { const payload = {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: {}, data: {},
}; };
@@ -3535,7 +3541,7 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}); });
const payload = { const payload = {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { invalidColumn: 'value' }, data: { invalidColumn: 'value' },
}; };
@@ -3554,7 +3560,10 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}); });
const payload = { const payload = {
filter: { invalidColumn: 'Alice' }, filter: {
type: 'and',
filters: [{ columnName: 'invalidColumn', condition: 'eq', value: 'Alice' }],
},
data: { name: 'Updated' }, data: { name: 'Updated' },
}; };
@@ -3576,7 +3585,10 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}); });
const payload = { const payload = {
filter: { age: 'invalid_number' }, filter: {
type: 'and',
filters: [{ columnName: 'age', condition: 'eq', value: 'invalid_number' }],
},
data: { name: 'Updated' }, data: { name: 'Updated' },
}; };
@@ -3598,7 +3610,7 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}); });
const payload = { const payload = {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { age: 'invalid_number' }, data: { age: 'invalid_number' },
}; };
@@ -3621,7 +3633,7 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}); });
const payload = { const payload = {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { age: 31 }, // Only updating age, not name or active data: { age: 31 }, // Only updating age, not name or active
}; };
@@ -3652,7 +3664,7 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}); });
const payload = { const payload = {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { birthdate: '1995-05-15T12:30:00.000Z' }, data: { birthdate: '1995-05-15T12:30:00.000Z' },
}; };
@@ -3687,7 +3699,7 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}); });
const payload = { const payload = {
filter: { active: true }, filter: { type: 'and', filters: [{ columnName: 'active', condition: 'eq', value: true }] },
data: { active: false }, data: { active: false },
returnData: true, returnData: true,
}; };
@@ -3714,4 +3726,32 @@ describe('PATCH /projects/:projectId/data-tables/:dataStoreId/rows', () => {
}, },
]); ]);
}); });
test.each(['like', 'ilike'])(
'should auto-wrap %s filters if no wildcard is present',
async (condition) => {
const dataStore = await createDataStore(memberProject, {
columns: [
{
name: 'name',
type: 'string',
},
],
data: [{ name: 'Alice Smith' }, { name: 'Bob Jones' }],
});
const payload = {
filter: { type: 'and', filters: [{ columnName: 'name', value: 'Alice', condition }] },
data: { name: 'Alice Johnson' },
returnData: true,
};
const result = await authMemberAgent
.patch(`/projects/${memberProject.id}/data-tables/${dataStore.id}/rows`)
.send(payload)
.expect(200);
expect(result.body.data).toEqual([expect.objectContaining({ name: 'Alice Johnson' })]);
},
);
}); });

View File

@@ -3,6 +3,7 @@ import type { AddDataStoreColumnDto, CreateDataStoreColumnDto } from '@n8n/api-t
import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils';
import type { Project } from '@n8n/db'; import type { Project } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { DataStoreRow } from 'n8n-workflow';
import { DataStoreRowsRepository } from '../data-store-rows.repository'; import { DataStoreRowsRepository } from '../data-store-rows.repository';
import { DataStoreRepository } from '../data-store.repository'; import { DataStoreRepository } from '../data-store.repository';
@@ -842,18 +843,17 @@ describe('dataStore', () => {
{}, {},
); );
expect(count).toEqual(4); expect(count).toEqual(4);
expect(data).toEqual(
rows.map((row, i) => const expected = rows.map(
expect.objectContaining({ (row, i) =>
expect.objectContaining<DataStoreRow>({
...row, ...row,
id: i + 1, id: i + 1,
c1: row.c1,
c2: row.c2,
c3: typeof row.c3 === 'string' ? new Date(row.c3) : row.c3, c3: typeof row.c3 === 'string' ? new Date(row.c3) : row.c3,
c4: row.c4, }) as jest.AsymmetricMatcher,
}),
),
); );
expect(data).toEqual(expected);
}); });
it('inserts a row even if it matches with the existing one', async () => { it('inserts a row even if it matches with the existing one', async () => {
@@ -1161,7 +1161,7 @@ describe('dataStore', () => {
); );
}); });
it('rejects a invalid date string to date column', async () => { it('rejects an invalid date string to date column', async () => {
// ARRANGE // ARRANGE
const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, { const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, {
name: 'dataStore', name: 'dataStore',
@@ -1174,10 +1174,9 @@ describe('dataStore', () => {
]); ]);
// ASSERT // ASSERT
await expect(result).rejects.toThrow(DataStoreValidationError);
await expect(result).rejects.toThrow( await expect(result).rejects.toThrow(
new DataStoreValidationError( "value '2025-99-15T09:48:14.259Z' does not match column type 'date'",
"value '2025-99-15T09:48:14.259Z' does not match column type 'date'",
),
); );
}); });
@@ -1211,14 +1210,16 @@ describe('dataStore', () => {
}); });
// ACT // ACT
const wrongValue = new Date().toISOString();
const result = dataStoreService.insertRows(dataStoreId, project1.id, [ const result = dataStoreService.insertRows(dataStoreId, project1.id, [
{ c1: 3 }, { c1: 3 },
{ c1: true }, { c1: wrongValue },
]); ]);
// ASSERT // ASSERT
await expect(result).rejects.toThrow(DataStoreValidationError);
await expect(result).rejects.toThrow( await expect(result).rejects.toThrow(
new DataStoreValidationError("value 'true' does not match column type 'number'"), `value '${wrongValue}' does not match column type 'number'`,
); );
}); });
@@ -1636,7 +1637,7 @@ describe('dataStore', () => {
// ACT // ACT
const result = await dataStoreService.updateRow(dataStoreId, project1.id, { const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { name: 'Alicia', age: 31, active: false, birthday: new Date('1990-01-02') }, data: { name: 'Alicia', age: 31, active: false, birthday: new Date('1990-01-02') },
}); });
@@ -1690,7 +1691,7 @@ describe('dataStore', () => {
// ACT // ACT
const result = await dataStoreService.updateRow(dataStoreId, project1.id, { const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
filter: { id: 1 }, filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] },
data: { name: 'Alicia', age: 31, active: false }, data: { name: 'Alicia', age: 31, active: false },
}); });
@@ -1742,7 +1743,7 @@ describe('dataStore', () => {
// ACT // ACT
const result = await dataStoreService.updateRow(dataStoreId, project1.id, { const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { age: 31, active: false }, data: { age: 31, active: false },
}); });
@@ -1785,7 +1786,7 @@ describe('dataStore', () => {
// ACT // ACT
const result = await dataStoreService.updateRow(dataStoreId, project1.id, { const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { name: 'Alicia' }, data: { name: 'Alicia' },
}); });
@@ -1832,7 +1833,7 @@ describe('dataStore', () => {
// ACT // ACT
const result = await dataStoreService.updateRow(dataStoreId, project1.id, { const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
filter: { age: 30 }, filter: { type: 'and', filters: [{ columnName: 'age', condition: 'eq', value: 30 }] },
data: { age: 31 }, data: { age: 31 },
}); });
@@ -1860,6 +1861,51 @@ describe('dataStore', () => {
); );
}); });
it('should be able to update by numeric string', async () => {
// ARRANGE
const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, {
name: 'dataStore',
columns: [{ name: 'age', type: 'number' }],
});
await dataStoreService.insertRows(dataStoreId, project1.id, [{ age: 30 }]);
// ACT
const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
filter: { type: 'and', filters: [{ columnName: 'age', condition: 'eq', value: '30' }] },
data: { age: '31' },
});
// ASSERT
expect(result).toEqual(true);
const { data } = await dataStoreService.getManyRowsAndCount(dataStoreId, project1.id, {});
expect(data).toEqual([expect.objectContaining({ age: 31 })]);
});
it('should throw on invalid numeric string', async () => {
// ARRANGE
const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, {
name: 'dataStore',
columns: [{ name: 'age', type: 'number' }],
});
await dataStoreService.insertRows(dataStoreId, project1.id, [{ age: 30 }]);
// ACT
const result = dataStoreService.updateRow(dataStoreId, project1.id, {
filter: {
type: 'and',
filters: [{ columnName: 'age', condition: 'eq', value: '30dfddf' }],
},
data: { age: '31' },
});
// ASSERT
await expect(result).rejects.toThrow(DataStoreValidationError);
await expect(result).rejects.toThrow("value '30dfddf' does not match column type 'number'");
});
it('should be able to update by boolean column', async () => { it('should be able to update by boolean column', async () => {
// ARRANGE // ARRANGE
const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, { const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, {
@@ -1879,7 +1925,7 @@ describe('dataStore', () => {
// ACT // ACT
const result = await dataStoreService.updateRow(dataStoreId, project1.id, { const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
filter: { active: true }, filter: { type: 'and', filters: [{ columnName: 'active', condition: 'eq', value: true }] },
data: { active: false }, data: { active: false },
}); });
@@ -1909,6 +1955,9 @@ describe('dataStore', () => {
it('should be able to update by date column', async () => { it('should be able to update by date column', async () => {
// ARRANGE // ARRANGE
const aliceBirthday = new Date('1990-01-02');
const bobBirthday = new Date('1995-01-01');
const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, { const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, {
name: 'dataStore', name: 'dataStore',
columns: [ columns: [
@@ -1920,14 +1969,18 @@ describe('dataStore', () => {
}); });
await dataStoreService.insertRows(dataStoreId, project1.id, [ await dataStoreService.insertRows(dataStoreId, project1.id, [
{ name: 'Alice', age: 30, active: true, birthday: new Date('1990-01-01') }, { name: 'Alice', age: 30, active: true, birthday: aliceBirthday },
{ name: 'Bob', age: 25, active: false, birthday: new Date('1995-01-01') }, { name: 'Bob', age: 25, active: false, birthday: bobBirthday },
]); ]);
// ACT // ACT
const newBirthday = new Date('1990-01-03');
const result = await dataStoreService.updateRow(dataStoreId, project1.id, { const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
filter: { birthday: new Date('1990-01-01') }, filter: {
data: { birthday: new Date('1990-01-02') }, type: 'and',
filters: [{ columnName: 'birthday', condition: 'eq', value: aliceBirthday }],
},
data: { birthday: newBirthday },
}); });
// ASSERT // ASSERT
@@ -1941,14 +1994,14 @@ describe('dataStore', () => {
name: 'Alice', name: 'Alice',
age: 30, age: 30,
active: true, active: true,
birthday: new Date('1990-01-02'), birthday: newBirthday,
}), }),
expect.objectContaining({ expect.objectContaining({
id: 2, id: 2,
name: 'Bob', name: 'Bob',
age: 25, age: 25,
active: false, active: false,
birthday: new Date('1995-01-01'), birthday: bobBirthday,
}), }),
]), ]),
); );
@@ -1973,7 +2026,13 @@ describe('dataStore', () => {
// ACT // ACT
const result = await dataStoreService.updateRow(dataStoreId, project1.id, { const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
filter: { name: 'Alice', age: 30 }, filter: {
type: 'and',
filters: [
{ columnName: 'name', condition: 'eq', value: 'Alice' },
{ columnName: 'age', condition: 'eq', value: 30 },
],
},
data: { department: 'Management' }, data: { department: 'Management' },
}); });
@@ -2019,7 +2078,10 @@ describe('dataStore', () => {
// ACT // ACT
const result = await dataStoreService.updateRow(dataStoreId, project1.id, { const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
filter: { name: 'Charlie' }, filter: {
type: 'and',
filters: [{ columnName: 'name', condition: 'eq', value: 'Charlie' }],
},
data: { age: 25 }, data: { age: 25 },
}); });
@@ -2049,13 +2111,13 @@ describe('dataStore', () => {
// ACT // ACT
const result = dataStoreService.updateRow(dataStoreId, project1.id, { const result = dataStoreService.updateRow(dataStoreId, project1.id, {
filter: {}, filter: { type: 'and', filters: [] },
data: { name: 'Alice', age: 31 }, data: { name: 'Alice', age: 31 },
}); });
// ASSERT // ASSERT
await expect(result).rejects.toThrow( await expect(result).rejects.toThrow(
new DataStoreValidationError('Filter columns must not be empty for updateRow'), new DataStoreValidationError('Filter must not be empty for updateRow'),
); );
const { data } = await dataStoreService.getManyRowsAndCount(dataStoreId, project1.id, {}); const { data } = await dataStoreService.getManyRowsAndCount(dataStoreId, project1.id, {});
@@ -2081,7 +2143,7 @@ describe('dataStore', () => {
// ACT // ACT
const result = dataStoreService.updateRow(dataStoreId, project1.id, { const result = dataStoreService.updateRow(dataStoreId, project1.id, {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: {}, data: {},
}); });
@@ -2102,7 +2164,7 @@ describe('dataStore', () => {
it('should fail when data store does not exist', async () => { it('should fail when data store does not exist', async () => {
// ACT & ASSERT // ACT & ASSERT
const result = dataStoreService.updateRow('non-existent-id', project1.id, { const result = dataStoreService.updateRow('non-existent-id', project1.id, {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { age: 25 }, data: { age: 25 },
}); });
@@ -2120,7 +2182,7 @@ describe('dataStore', () => {
// ACT & ASSERT // ACT & ASSERT
const result = dataStoreService.updateRow(dataStoreId, project1.id, { const result = dataStoreService.updateRow(dataStoreId, project1.id, {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { invalidColumn: 'value' }, data: { invalidColumn: 'value' },
}); });
@@ -2138,7 +2200,10 @@ describe('dataStore', () => {
// ACT & ASSERT // ACT & ASSERT
const result = dataStoreService.updateRow(dataStoreId, project1.id, { const result = dataStoreService.updateRow(dataStoreId, project1.id, {
filter: { invalidColumn: 'Alice' }, filter: {
type: 'and',
filters: [{ columnName: 'invalidColumn', condition: 'eq', value: 'Alice' }],
},
data: { name: 'Bob' }, data: { name: 'Bob' },
}); });
@@ -2159,7 +2224,7 @@ describe('dataStore', () => {
// ACT & ASSERT // ACT & ASSERT
const result = dataStoreService.updateRow(dataStoreId, project1.id, { const result = dataStoreService.updateRow(dataStoreId, project1.id, {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { age: 'not-a-number' }, data: { age: 'not-a-number' },
}); });
@@ -2183,7 +2248,7 @@ describe('dataStore', () => {
// ACT - only update age, leaving name and active unchanged // ACT - only update age, leaving name and active unchanged
const result = await dataStoreService.updateRow(dataStoreId, project1.id, { const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { age: 31 }, data: { age: 31 },
}); });
@@ -2218,7 +2283,7 @@ describe('dataStore', () => {
// ACT // ACT
const newDate = new Date('1991-02-02'); const newDate = new Date('1991-02-02');
const result = await dataStoreService.updateRow(dataStoreId, project1.id, { const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
filter: { name: 'Alice' }, filter: { type: 'and', filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }] },
data: { birthDate: newDate.toISOString() }, data: { birthDate: newDate.toISOString() },
}); });
@@ -2256,7 +2321,10 @@ describe('dataStore', () => {
dataStoreId, dataStoreId,
project1.id, project1.id,
{ {
filter: { name: 'Alice' }, filter: {
type: 'and',
filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }],
},
data: { age: 31, active: false, timestamp: soon }, data: { age: 31, active: false, timestamp: soon },
}, },
true, true,
@@ -2342,5 +2410,27 @@ describe('dataStore', () => {
}, },
]); ]);
}); });
it('should fail when filter contains invalid column names', async () => {
// ARRANGE
const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, {
name: 'dataStore',
columns: [{ name: 'name', type: 'string' }],
});
await dataStoreService.insertRows(dataStoreId, project1.id, [{ name: 'Alice' }]);
// ACT
const result = dataStoreService.updateRow(dataStoreId, project1.id, {
filter: {
type: 'and',
filters: [{ columnName: 'invalidColumn', condition: 'eq', value: 'Alice' }],
},
data: { name: 'Bob' },
});
// ASSERT
await expect(result).rejects.toThrow(DataStoreValidationError);
});
}); });
}); });

View File

@@ -1,8 +1,16 @@
import type { ListDataStoreContentQueryDto, ListDataStoreContentFilter } from '@n8n/api-types'; import type { ListDataStoreContentQueryDto, DataTableFilter } from '@n8n/api-types';
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { CreateTable, DslColumn } from '@n8n/db'; import { CreateTable, DslColumn } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { DataSource, DataSourceOptions, QueryRunner, SelectQueryBuilder, In } from '@n8n/typeorm'; import {
DataSource,
DataSourceOptions,
QueryRunner,
SelectQueryBuilder,
UpdateQueryBuilder,
In,
ObjectLiteral,
} from '@n8n/typeorm';
import { import {
DataStoreColumnJsType, DataStoreColumnJsType,
DataStoreRows, DataStoreRows,
@@ -44,20 +52,23 @@ type QueryBuilder = SelectQueryBuilder<any>;
* - MySQL/MariaDB: the SQL literal itself requires two backslashes (`'\\'`) to mean one. * - MySQL/MariaDB: the SQL literal itself requires two backslashes (`'\\'`) to mean one.
*/ */
function getConditionAndParams( function getConditionAndParams(
filter: ListDataStoreContentFilter['filters'][number], filter: DataTableFilter['filters'][number],
index: number, index: number,
dbType: DataSourceOptions['type'], dbType: DataSourceOptions['type'],
tableReference?: string,
columns?: DataTableColumn[], columns?: DataTableColumn[],
): [string, Record<string, unknown>] { ): [string, Record<string, unknown>] {
const paramName = `filter_${index}`; const paramName = `filter_${index}`;
const column = `${quoteIdentifier('dataStore', dbType)}.${quoteIdentifier(filter.columnName, dbType)}`; const columnRef = tableReference
? `${quoteIdentifier(tableReference, dbType)}.${quoteIdentifier(filter.columnName, dbType)}`
: quoteIdentifier(filter.columnName, dbType);
if (filter.value === null) { if (filter.value === null) {
switch (filter.condition) { switch (filter.condition) {
case 'eq': case 'eq':
return [`${column} IS NULL`, {}]; return [`${columnRef} IS NULL`, {}];
case 'neq': case 'neq':
return [`${column} IS NOT NULL`, {}]; return [`${columnRef} IS NOT NULL`, {}];
} }
} }
@@ -76,7 +87,7 @@ function getConditionAndParams(
}; };
if (operators[filter.condition]) { if (operators[filter.condition]) {
return [`${column} ${operators[filter.condition]} :${paramName}`, { [paramName]: value }]; return [`${columnRef} ${operators[filter.condition]} :${paramName}`, { [paramName]: value }];
} }
switch (filter.condition) { switch (filter.condition) {
@@ -84,29 +95,32 @@ function getConditionAndParams(
case 'like': case 'like':
if (['sqlite', 'sqlite-pooled'].includes(dbType)) { if (['sqlite', 'sqlite-pooled'].includes(dbType)) {
const globValue = toSqliteGlobFromPercent(value as string); const globValue = toSqliteGlobFromPercent(value as string);
return [`${column} GLOB :${paramName}`, { [paramName]: globValue }]; return [`${columnRef} GLOB :${paramName}`, { [paramName]: globValue }];
} }
if (['mysql', 'mariadb'].includes(dbType)) { if (['mysql', 'mariadb'].includes(dbType)) {
const escapedValue = escapeLikeSpecials(value as string); const escapedValue = escapeLikeSpecials(value as string);
return [`${column} LIKE BINARY :${paramName} ESCAPE '\\\\'`, { [paramName]: escapedValue }]; return [
`${columnRef} LIKE BINARY :${paramName} ESCAPE '\\\\'`,
{ [paramName]: escapedValue },
];
} }
// PostgreSQL: LIKE is case-sensitive // PostgreSQL: LIKE is case-sensitive
if (dbType === 'postgres') { if (dbType === 'postgres') {
const escapedValue = escapeLikeSpecials(value as string); const escapedValue = escapeLikeSpecials(value as string);
return [`${column} LIKE :${paramName} ESCAPE '\\'`, { [paramName]: escapedValue }]; return [`${columnRef} LIKE :${paramName} ESCAPE '\\'`, { [paramName]: escapedValue }];
} }
// Generic fallback // Generic fallback
return [`${column} LIKE :${paramName}`, { [paramName]: value }]; return [`${columnRef} LIKE :${paramName}`, { [paramName]: value }];
// case-insensitive // case-insensitive
case 'ilike': case 'ilike':
if (['sqlite', 'sqlite-pooled'].includes(dbType)) { if (['sqlite', 'sqlite-pooled'].includes(dbType)) {
const escapedValue = escapeLikeSpecials(value as string); const escapedValue = escapeLikeSpecials(value as string);
return [ return [
`UPPER(${column}) LIKE UPPER(:${paramName}) ESCAPE '\\'`, `UPPER(${columnRef}) LIKE UPPER(:${paramName}) ESCAPE '\\'`,
{ [paramName]: escapedValue }, { [paramName]: escapedValue },
]; ];
} }
@@ -114,17 +128,17 @@ function getConditionAndParams(
if (['mysql', 'mariadb'].includes(dbType)) { if (['mysql', 'mariadb'].includes(dbType)) {
const escapedValue = escapeLikeSpecials(value as string); const escapedValue = escapeLikeSpecials(value as string);
return [ return [
`UPPER(${column}) LIKE UPPER(:${paramName}) ESCAPE '\\\\'`, `UPPER(${columnRef}) LIKE UPPER(:${paramName}) ESCAPE '\\\\'`,
{ [paramName]: escapedValue }, { [paramName]: escapedValue },
]; ];
} }
if (dbType === 'postgres') { if (dbType === 'postgres') {
const escapedValue = escapeLikeSpecials(value as string); const escapedValue = escapeLikeSpecials(value as string);
return [`${column} ILIKE :${paramName} ESCAPE '\\'`, { [paramName]: escapedValue }]; return [`${columnRef} ILIKE :${paramName} ESCAPE '\\'`, { [paramName]: escapedValue }];
} }
return [`UPPER(${column}) LIKE UPPER(:${paramName})`, { [paramName]: value }]; return [`UPPER(${columnRef}) LIKE UPPER(:${paramName})`, { [paramName]: value }];
} }
// This should never happen as all valid conditions are handled above // This should never happen as all valid conditions are handled above
@@ -218,7 +232,7 @@ export class DataStoreRowsRepository {
async updateRow( async updateRow(
dataStoreId: string, dataStoreId: string,
setData: Record<string, DataStoreColumnJsType | null>, setData: Record<string, DataStoreColumnJsType | null>,
whereData: Record<string, DataStoreColumnJsType | null>, filter: DataTableFilter,
columns: DataTableColumn[], columns: DataTableColumn[],
returnData: boolean = false, returnData: boolean = false,
) { ) {
@@ -236,26 +250,26 @@ export class DataStoreRowsRepository {
if (column.name in setData) { if (column.name in setData) {
setData[column.name] = normalizeValue(setData[column.name], column.type, dbType); setData[column.name] = normalizeValue(setData[column.name], column.type, dbType);
} }
if (column.name in whereData) {
whereData[column.name] = normalizeValue(whereData[column.name], column.type, dbType);
}
} }
let affectedRows: Array<Pick<DataStoreRowReturn, 'id'>> = []; let affectedRows: Array<Pick<DataStoreRowReturn, 'id'>> = [];
if (!useReturning && returnData) { if (!useReturning && returnData) {
// Only Postgres supports RETURNING statement on updates (with our typeorm), // Only Postgres supports RETURNING statement on updates (with our typeorm),
// on other engines we must query the list of updates rows later by ID // on other engines we must query the list of updates rows later by ID
affectedRows = await this.dataSource const selectQuery = this.dataSource
.createQueryBuilder() .createQueryBuilder()
.select('id') .select('id')
.from(table, 'dataStore') .from(table, 'dataTable');
.where(whereData) this.applyFilters(selectQuery, filter, 'dataTable', columns);
.getRawMany<{ id: number }>(); affectedRows = await selectQuery.getRawMany<{ id: number }>();
} }
setData.updatedAt = normalizeValue(new Date(), 'date', dbType); setData.updatedAt = normalizeValue(new Date(), 'date', dbType);
const query = this.dataSource.createQueryBuilder().update(table).set(setData).where(whereData); const query = this.dataSource.createQueryBuilder().update(table);
// Some DBs (like SQLite) don't allow using table aliases as column prefixes in UPDATE statements
this.applyFilters(query, filter, undefined, columns);
query.set(setData);
if (useReturning && returnData) { if (useReturning && returnData) {
query.returning(selectColumns.join(',')); query.returning(selectColumns.join(','));
@@ -316,7 +330,16 @@ export class DataStoreRowsRepository {
const setData = Object.fromEntries(updateKeys.map((key) => [key, row[key]])); const setData = Object.fromEntries(updateKeys.map((key) => [key, row[key]]));
const whereData = Object.fromEntries(matchFields.map((key) => [key, row[key]])); const whereData = Object.fromEntries(matchFields.map((key) => [key, row[key]]));
const result = await this.updateRow(dataStoreId, setData, whereData, columns, returnData); // Convert whereData object to DataTableFilter format
const filter: DataTableFilter = {
type: 'and',
filters: Object.entries(whereData).map(([columnName, value]) => ({
columnName,
condition: 'eq' as const,
value,
})),
};
const result = await this.updateRow(dataStoreId, setData, filter, columns, returnData);
if (returnData) { if (returnData) {
output.push.apply(output, result); output.push.apply(output, result);
} }
@@ -336,7 +359,7 @@ export class DataStoreRowsRepository {
await this.dataSource await this.dataSource
.createQueryBuilder() .createQueryBuilder()
.delete() .delete()
.from(table, 'dataStore') .from(table, 'dataTable')
.where({ id: In(ids) }) .where({ id: In(ids) })
.execute(); .execute();
@@ -408,7 +431,7 @@ export class DataStoreRowsRepository {
const updatedRows = await this.dataSource const updatedRows = await this.dataSource
.createQueryBuilder() .createQueryBuilder()
.select(selectColumns) .select(selectColumns)
.from(table, 'dataStore') .from(table, 'dataTable')
.where({ id: In(ids) }) .where({ id: In(ids) })
.getRawMany<DataStoreRowReturn>(); .getRawMany<DataStoreRowReturn>();
@@ -428,8 +451,11 @@ export class DataStoreRowsRepository {
): [QueryBuilder, QueryBuilder] { ): [QueryBuilder, QueryBuilder] {
const query = this.dataSource.createQueryBuilder(); const query = this.dataSource.createQueryBuilder();
query.from(this.toTableName(dataStoreId), 'dataStore'); const tableReference = 'dataTable';
this.applyFilters(query, dto, columns); query.from(this.toTableName(dataStoreId), tableReference);
if (dto.filter) {
this.applyFilters(query, dto.filter, tableReference, columns);
}
const countQuery = query.clone().select('COUNT(*)'); const countQuery = query.clone().select('COUNT(*)');
this.applySorting(query, dto); this.applySorting(query, dto);
this.applyPagination(query, dto); this.applyPagination(query, dto);
@@ -437,17 +463,18 @@ export class DataStoreRowsRepository {
return [countQuery, query]; return [countQuery, query];
} }
private applyFilters( private applyFilters<T extends ObjectLiteral>(
query: QueryBuilder, query: SelectQueryBuilder<T> | UpdateQueryBuilder<T>,
dto: ListDataStoreContentQueryDto, filter: DataTableFilter,
tableReference?: string,
columns?: DataTableColumn[], columns?: DataTableColumn[],
): void { ): void {
const filters = dto.filter?.filters ?? []; const filters = filter.filters ?? [];
const filterType = dto.filter?.type ?? 'and'; const filterType = filter.type ?? 'and';
const dbType = this.dataSource.options.type; const dbType = this.dataSource.options.type;
const conditionsAndParams = filters.map((filter, i) => const conditionsAndParams = filters.map((filter, i) =>
getConditionAndParams(filter, i, dbType, columns), getConditionAndParams(filter, i, dbType, tableReference, columns),
); );
for (const [condition, params] of conditionsAndParams) { for (const [condition, params] of conditionsAndParams) {
@@ -470,7 +497,7 @@ export class DataStoreRowsRepository {
private applySortingByField(query: QueryBuilder, field: string, direction: 'DESC' | 'ASC'): void { private applySortingByField(query: QueryBuilder, field: string, direction: 'DESC' | 'ASC'): void {
const dbType = this.dataSource.options.type; const dbType = this.dataSource.options.type;
const quotedField = `${quoteIdentifier('dataStore', dbType)}.${quoteIdentifier(field, dbType)}`; const quotedField = `${quoteIdentifier('dataTable', dbType)}.${quoteIdentifier(field, dbType)}`;
query.orderBy(quotedField, direction); query.orderBy(quotedField, direction);
} }
@@ -487,7 +514,7 @@ export class DataStoreRowsRepository {
const queryBuilder = this.dataSource const queryBuilder = this.dataSource
.createQueryBuilder() .createQueryBuilder()
.select(matchFields) .select(matchFields)
.from(this.toTableName(dataStoreId), 'datastore'); .from(this.toTableName(dataStoreId), 'datatable');
rows.forEach((row, index) => { rows.forEach((row, index) => {
const matchData = Object.fromEntries(matchFields.map((field) => [field, row[field]])); const matchData = Object.fromEntries(matchFields.map((field) => [field, row[field]]));

View File

@@ -7,7 +7,7 @@ import {
ListDataStoreQueryDto, ListDataStoreQueryDto,
MoveDataStoreColumnDto, MoveDataStoreColumnDto,
UpdateDataStoreDto, UpdateDataStoreDto,
UpdateDataStoreRowDto, UpdateDataTableRowDto,
UpsertDataStoreRowsDto, UpsertDataStoreRowsDto,
} from '@n8n/api-types'; } from '@n8n/api-types';
import { AuthenticatedRequest } from '@n8n/db'; import { AuthenticatedRequest } from '@n8n/db';
@@ -306,7 +306,7 @@ export class DataStoreController {
req: AuthenticatedRequest<{ projectId: string }>, req: AuthenticatedRequest<{ projectId: string }>,
_res: Response, _res: Response,
@Param('dataStoreId') dataStoreId: string, @Param('dataStoreId') dataStoreId: string,
@Body dto: UpdateDataStoreRowDto, @Body dto: UpdateDataTableRowDto,
) { ) {
try { try {
return await this.dataStoreService.updateRow( return await this.dataStoreService.updateRow(

View File

@@ -1,4 +1,3 @@
import { dateTimeSchema } from '@n8n/api-types';
import type { import type {
AddDataStoreColumnDto, AddDataStoreColumnDto,
CreateDataStoreDto, CreateDataStoreDto,
@@ -7,15 +6,25 @@ import type {
DataStoreListOptions, DataStoreListOptions,
UpsertDataStoreRowsDto, UpsertDataStoreRowsDto,
UpdateDataStoreDto, UpdateDataStoreDto,
UpdateDataStoreRowDto, UpdateDataTableRowDto,
} from '@n8n/api-types'; } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common'; import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { DataStoreRow, DataStoreRowReturn, DataStoreRows } from 'n8n-workflow'; import { DateTime } from 'luxon';
import type {
DataStoreColumnJsType,
DataTableFilter,
DataStoreRow,
DataStoreRowReturn,
DataStoreRows,
} from 'n8n-workflow';
import { validateFieldType } from 'n8n-workflow';
import { DataStoreColumnRepository } from './data-store-column.repository'; import { DataStoreColumnRepository } from './data-store-column.repository';
import { DataStoreRowsRepository } from './data-store-rows.repository'; import { DataStoreRowsRepository } from './data-store-rows.repository';
import { DataStoreRepository } from './data-store.repository'; import { DataStoreRepository } from './data-store.repository';
import { columnTypeToFieldType } from './data-store.types';
import { DataTableColumn } from './data-table-column.entity';
import { DataStoreColumnNotFoundError } from './errors/data-store-column-not-found.error'; import { DataStoreColumnNotFoundError } from './errors/data-store-column-not-found.error';
import { DataStoreNameConflictError } from './errors/data-store-name-conflict.error'; import { DataStoreNameConflictError } from './errors/data-store-name-conflict.error';
import { DataStoreNotFoundError } from './errors/data-store-not-found.error'; import { DataStoreNotFoundError } from './errors/data-store-not-found.error';
@@ -107,12 +116,11 @@ export class DataStoreService {
dto: ListDataStoreContentQueryDto, dto: ListDataStoreContentQueryDto,
) { ) {
await this.validateDataStoreExists(dataStoreId, projectId); await this.validateDataStoreExists(dataStoreId, projectId);
this.validateAndTransformFilters(dto);
// unclear if we should validate here, only use case would be to reduce the chance of
// a renamed/removed column appearing here (or added column missing) if the store was
// modified between when the frontend sent the request and we received it
const columns = await this.dataStoreColumnRepository.getColumns(dataStoreId); const columns = await this.dataStoreColumnRepository.getColumns(dataStoreId);
if (dto.filter) {
this.validateAndTransformFilters(dto.filter, columns);
}
const result = await this.dataStoreRowsRepository.getManyAndCount(dataStoreId, dto, columns); const result = await this.dataStoreRowsRepository.getManyAndCount(dataStoreId, dto, columns);
return { return {
count: result.count, count: result.count,
@@ -177,39 +185,39 @@ export class DataStoreService {
} }
async updateRow<T extends boolean | undefined>( async updateRow<T extends boolean | undefined>(
dataStoreId: string, dataTableId: string,
projectId: string, projectId: string,
dto: Omit<UpdateDataStoreRowDto, 'returnData'>, dto: Omit<UpdateDataTableRowDto, 'returnData'>,
returnData?: T, returnData?: T,
): Promise<T extends true ? DataStoreRowReturn[] : true>; ): Promise<T extends true ? DataStoreRowReturn[] : true>;
async updateRow( async updateRow(
dataStoreId: string, dataTableId: string,
projectId: string, projectId: string,
dto: Omit<UpdateDataStoreRowDto, 'returnData'>, dto: Omit<UpdateDataTableRowDto, 'returnData'>,
returnData = false, returnData = false,
) { ) {
await this.validateDataStoreExists(dataStoreId, projectId); await this.validateDataStoreExists(dataTableId, projectId);
const columns = await this.dataStoreColumnRepository.getColumns(dataStoreId); const columns = await this.dataStoreColumnRepository.getColumns(dataTableId);
if (columns.length === 0) { if (columns.length === 0) {
throw new DataStoreValidationError( throw new DataStoreValidationError(
'No columns found for this data store or data store not found', 'No columns found for this data table or data table not found',
); );
} }
const { data, filter } = dto; const { data, filter } = dto;
if (!filter || Object.keys(filter).length === 0) { if (!filter?.filters || filter.filters.length === 0) {
throw new DataStoreValidationError('Filter columns must not be empty for updateRow'); throw new DataStoreValidationError('Filter must not be empty for updateRow');
} }
if (!data || Object.keys(data).length === 0) { if (!data || Object.keys(data).length === 0) {
throw new DataStoreValidationError('Data columns must not be empty for updateRow'); throw new DataStoreValidationError('Data columns must not be empty for updateRow');
} }
this.validateRowsWithColumns([filter], columns, true);
this.validateRowsWithColumns([data], columns, false); this.validateRowsWithColumns([data], columns, false);
this.validateAndTransformFilters(filter, columns);
return await this.dataStoreRowsRepository.updateRow( return await this.dataStoreRowsRepository.updateRow(
dataStoreId, dataTableId,
data, data,
filter, filter,
columns, columns,
@@ -264,42 +272,36 @@ export class DataStoreService {
if (cell === null) return; if (cell === null) return;
const columnType = columnTypeMap.get(key); const columnType = columnTypeMap.get(key);
switch (columnType) { if (!columnType) return;
case 'boolean':
if (typeof cell !== 'boolean') {
throw new DataStoreValidationError(
`value '${String(cell)}' does not match column type 'boolean'`,
);
}
break;
case 'date':
if (typeof cell === 'string') {
const validated = dateTimeSchema.safeParse(cell);
if (validated.success) {
row[key] = validated.data.toISOString();
break;
}
} else if (cell instanceof Date) {
row[key] = cell.toISOString();
break;
}
throw new DataStoreValidationError(`value '${cell}' does not match column type 'date'`); const fieldType = columnTypeToFieldType[columnType];
case 'string': if (!fieldType) return;
if (typeof cell !== 'string') {
throw new DataStoreValidationError( const validationResult = validateFieldType(key, cell, fieldType, {
`value '${String(cell)}' does not match column type 'string'`, strict: false, // Allow type coercion (e.g., string numbers to numbers)
); parseStrings: false,
} });
break;
case 'number': if (!validationResult.valid) {
if (typeof cell !== 'number') { throw new DataStoreValidationError(
throw new DataStoreValidationError( `value '${String(cell)}' does not match column type '${columnType}': ${validationResult.errorMessage}`,
`value '${String(cell)}' does not match column type 'number'`, );
);
}
break;
} }
// Special handling for date type to convert from luxon DateTime to ISO string
if (columnType === 'date') {
try {
const dateInISO = (validationResult.newValue as DateTime).toISO();
row[key] = dateInISO;
return;
} catch {
throw new DataStoreValidationError(
`value '${String(cell)}' does not match column type 'date'`,
);
}
}
row[key] = validationResult.newValue as DataStoreColumnJsType;
} }
private async validateDataStoreExists(dataStoreId: string, projectId: string) { private async validateDataStoreExists(dataStoreId: string, projectId: string) {
@@ -341,12 +343,21 @@ export class DataStoreService {
} }
} }
private validateAndTransformFilters(dto: ListDataStoreContentQueryDto): void { private validateAndTransformFilters(
if (!dto.filter?.filters) { filterObject: DataTableFilter,
return; columns: DataTableColumn[],
} ): void {
this.validateRowsWithColumns(
filterObject.filters.map((f) => {
return {
[f.columnName]: f.value,
};
}),
columns,
true,
);
for (const filter of dto.filter.filters) { for (const filter of filterObject.filters) {
if (['like', 'ilike'].includes(filter.condition)) { if (['like', 'ilike'].includes(filter.condition)) {
if (filter.value === null || filter.value === undefined) { if (filter.value === null || filter.value === undefined) {
throw new DataStoreValidationError( throw new DataStoreValidationError(

View File

@@ -1 +1,13 @@
import type { FieldTypeMap } from 'n8n-workflow';
export type DataStoreUserTableName = `${string}data_table_user_${string}`; export type DataStoreUserTableName = `${string}data_table_user_${string}`;
export const columnTypeToFieldType: Record<string, keyof FieldTypeMap> = {
// eslint-disable-next-line id-denylist
number: 'number',
// eslint-disable-next-line id-denylist
string: 'string',
// eslint-disable-next-line id-denylist
boolean: 'boolean',
date: 'dateTime',
};

View File

@@ -175,7 +175,10 @@ export const updateDataStoreRowsApi = async (
'PATCH', 'PATCH',
`/projects/${projectId}/data-tables/${dataStoreId}/rows`, `/projects/${projectId}/data-tables/${dataStoreId}/rows`,
{ {
filter: { id: rowId }, filter: {
type: 'and',
filters: [{ columnName: 'id', condition: 'eq', value: rowId }],
},
data: rowData, data: rowData,
}, },
); );

View File

@@ -7,7 +7,7 @@ import {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { makeAddRow, getAddRow } from '../../common/addRow'; import { makeAddRow, getAddRow } from '../../common/addRow';
import { executeSelectMany, getSelectFields } from '../../common/selectMany'; import { getSelectFields, getSelectFilter } from '../../common/selectMany';
import { getDataTableProxyExecute } from '../../common/utils'; import { getDataTableProxyExecute } from '../../common/utils';
export const FIELD: string = 'update'; export const FIELD: string = 'update';
@@ -31,26 +31,16 @@ export async function execute(
const dataStoreProxy = await getDataTableProxyExecute(this, index); const dataStoreProxy = await getDataTableProxyExecute(this, index);
const row = getAddRow(this, index); const row = getAddRow(this, index);
const filter = getSelectFilter(this, index);
const matches = await executeSelectMany(this, index, dataStoreProxy, true); if (filter.filters.length === 0) {
throw new NodeOperationError(this.getNode(), 'At least one condition is required');
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 })); const updatedRows = await dataStoreProxy.updateRows({
data: row,
filter,
});
return updatedRows.map((json) => ({ json }));
} }

View File

@@ -45,7 +45,10 @@ export async function execute(
for (const match of matches) { for (const match of matches) {
const updatedRows = await dataStoreProxy.updateRows({ const updatedRows = await dataStoreProxy.updateRows({
data: row, data: row,
filter: { id: match.json.id }, filter: {
type: 'and',
filters: [{ columnName: 'id', condition: 'eq', value: match.json.id }],
},
}); });
if (updatedRows.length !== 1) { if (updatedRows.length !== 1) {
throw new NodeOperationError(this.getNode(), 'invariant broken'); throw new NodeOperationError(this.getNode(), 'invariant broken');

View File

@@ -1,5 +1,6 @@
import { NodeOperationError } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow';
import type { import type {
DataTableFilter,
DataStoreRowReturn, DataStoreRowReturn,
IDataStoreProjectService, IDataStoreProjectService,
IDisplayOptions, IDisplayOptions,
@@ -94,7 +95,7 @@ export function getSelectFields(
]; ];
} }
export function getSelectFilter(ctx: IExecuteFunctions, index: number) { export function getSelectFilter(ctx: IExecuteFunctions, index: number): DataTableFilter {
const fields = ctx.getNodeParameter('filters.conditions', index, []); const fields = ctx.getNodeParameter('filters.conditions', index, []);
const matchType = ctx.getNodeParameter('matchType', index, ANY_CONDITION); const matchType = ctx.getNodeParameter('matchType', index, ANY_CONDITION);
const node = ctx.getNode(); const node = ctx.getNode();

View File

@@ -2,7 +2,7 @@ import { DateTime } from 'luxon';
import type { import type {
IDataObject, IDataObject,
INode, INode,
ListDataStoreContentFilter, DataTableFilter,
IDataStoreProjectAggregateService, IDataStoreProjectAggregateService,
IDataStoreProjectService, IDataStoreProjectService,
IExecuteFunctions, IExecuteFunctions,
@@ -82,7 +82,7 @@ export function isMatchType(obj: unknown): obj is FilterType {
export function buildGetManyFilter( export function buildGetManyFilter(
fieldEntries: FieldEntry[], fieldEntries: FieldEntry[],
matchType: FilterType, matchType: FilterType,
): ListDataStoreContentFilter { ): DataTableFilter {
const filters = fieldEntries.map((x) => { const filters = fieldEntries.map((x) => {
switch (x.condition) { switch (x.condition) {
case 'isEmpty': case 'isEmpty':

View File

@@ -41,7 +41,7 @@ export type ListDataStoreOptions = {
skip?: number; skip?: number;
}; };
export type ListDataStoreContentFilter = { export type DataTableFilter = {
type: 'and' | 'or'; type: 'and' | 'or';
filters: Array<{ filters: Array<{
columnName: string; columnName: string;
@@ -51,14 +51,14 @@ export type ListDataStoreContentFilter = {
}; };
export type ListDataStoreRowsOptions = { export type ListDataStoreRowsOptions = {
filter?: ListDataStoreContentFilter; filter?: DataTableFilter;
sortBy?: [string, 'ASC' | 'DESC']; sortBy?: [string, 'ASC' | 'DESC'];
take?: number; take?: number;
skip?: number; skip?: number;
}; };
export type UpdateDataStoreRowsOptions = { export type UpdateDataStoreRowsOptions = {
filter: Record<string, DataStoreColumnJsType>; filter: DataTableFilter;
data: DataStoreRow; data: DataStoreRow;
}; };