fix(core): Support inserting dates to data store via the insert endpoint (#18404)

This commit is contained in:
Jaakko Husso
2025-08-18 10:42:32 +03:00
committed by GitHub
parent 58aad35592
commit dc86984ae0
7 changed files with 270 additions and 12 deletions

View File

@@ -1,8 +1,11 @@
import { z } from 'zod';
import { Z } from 'zod-class';
import { dataStoreColumnNameSchema } from '../../schemas/data-store.schema';
import {
dataStoreColumnNameSchema,
dataStoreColumnValueSchema,
} from '../../schemas/data-store.schema';
export class AddDataStoreRowsDto extends Z.class({
data: z.array(z.record(dataStoreColumnNameSchema, z.any())),
data: z.array(z.record(dataStoreColumnNameSchema, dataStoreColumnValueSchema)),
}) {}

View File

@@ -56,4 +56,5 @@ export {
type DataStoreRows,
type DataStoreListOptions,
type DataStoreUserTableName,
dateTimeSchema,
} from './schemas/data-store.schema';

View File

@@ -51,6 +51,15 @@ export type DataStoreListOptions = Partial<ListDataStoreQueryDto> & {
filter: { projectId: string };
};
export const dateTimeSchema = z
.string()
.datetime({ offset: true })
.transform((s) => new Date(s))
.pipe(z.date());
// Dates are received as date strings and validated before insertion
export const dataStoreColumnValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
export type DataStoreColumnJsType = string | number | boolean | Date;
export type DataStoreRows = Array<Record<string, DataStoreColumnJsType | null>>;

View File

@@ -119,6 +119,7 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
"dataStore:list",
"dataStore:readRow",
"dataStore:writeRow",
"dataStore:listProject",
"dataStore:*",
"*",
]

View File

@@ -1940,6 +1940,214 @@ describe('POST /projects/:projectId/data-stores/:dataStoreId/insert', () => {
const rowsInDb = await dataStoreRowsRepository.getManyAndCount(toTableName(dataStore.id), {});
expect(rowsInDb.count).toBe(0);
});
test('should insert columns with dates', async () => {
const dataStore = await createDataStore(memberProject, {
columns: [
{
name: 'a',
type: 'date',
},
{
name: 'b',
type: 'date',
},
],
});
const payload = {
data: [
{
a: '2025-08-15T09:48:14.259Z',
b: '2025-08-15T12:34:56+02:00',
},
],
};
await authMemberAgent
.post(`/projects/${memberProject.id}/data-stores/${dataStore.id}/insert`)
.send(payload)
.expect(200);
const readResponse = await authMemberAgent
.get(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
.expect(200);
expect(readResponse.body.data.count).toBe(1);
expect(readResponse.body.data.data[0]).toMatchObject({
a: '2025-08-15T09:48:14.259Z',
b: '2025-08-15T10:34:56.000Z',
});
});
test('should insert columns with strings', async () => {
const dataStore = await createDataStore(memberProject, {
columns: [
{
name: 'a',
type: 'string',
},
{
name: 'b',
type: 'string',
},
{
name: 'c',
type: 'string',
},
],
});
const payload = {
data: [
{
a: 'some string',
b: '',
c: '2025-08-15T09:48:14.259Z',
},
],
};
await authMemberAgent
.post(`/projects/${memberProject.id}/data-stores/${dataStore.id}/insert`)
.send(payload)
.expect(200);
const readResponse = await authMemberAgent
.get(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
.expect(200);
expect(readResponse.body.data.count).toBe(1);
expect(readResponse.body.data.data[0]).toMatchObject(payload.data[0]);
});
test('should insert columns with booleans', async () => {
const dataStore = await createDataStore(memberProject, {
columns: [
{
name: 'a',
type: 'boolean',
},
{
name: 'b',
type: 'boolean',
},
],
});
const payload = {
data: [
{
a: true,
b: false,
},
],
};
await authMemberAgent
.post(`/projects/${memberProject.id}/data-stores/${dataStore.id}/insert`)
.send(payload)
.expect(200);
const readResponse = await authMemberAgent
.get(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
.expect(200);
expect(readResponse.body.data.count).toBe(1);
expect(readResponse.body.data.data[0]).toMatchObject(payload.data[0]);
});
test('should insert columns with numbers', async () => {
const dataStore = await createDataStore(memberProject, {
columns: [
{
name: 'a',
type: 'number',
},
{
name: 'b',
type: 'number',
},
{
name: 'c',
type: 'number',
},
{
name: 'd',
type: 'number',
},
],
});
const payload = {
data: [
{
a: 1,
b: 0,
c: -1,
d: 0.2340439341231259,
},
],
};
await authMemberAgent
.post(`/projects/${memberProject.id}/data-stores/${dataStore.id}/insert`)
.send(payload)
.expect(200);
const readResponse = await authMemberAgent
.get(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
.expect(200);
expect(readResponse.body.data.count).toBe(1);
expect(readResponse.body.data.data[0]).toMatchObject(payload.data[0]);
});
test('should insert columns with null values', async () => {
const dataStore = await createDataStore(memberProject, {
columns: [
{
name: 'a',
type: 'string',
},
{
name: 'b',
type: 'number',
},
{
name: 'c',
type: 'boolean',
},
{
name: 'd',
type: 'date',
},
],
});
const payload = {
data: [
{
a: null,
b: null,
c: null,
d: null,
},
],
};
await authMemberAgent
.post(`/projects/${memberProject.id}/data-stores/${dataStore.id}/insert`)
.send(payload)
.expect(200);
const readResponse = await authMemberAgent
.get(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
.expect(200);
expect(readResponse.body.data.count).toBe(1);
expect(readResponse.body.data.data[0]).toMatchObject(payload.data[0]);
});
});
describe('DELETE /projects/:projectId/data-stores/:dataStoreId/rows', () => {

View File

@@ -875,6 +875,12 @@ describe('dataStore', () => {
{ c1: 3, c2: true, c3: new Date(), c4: 'hello?' },
{ c1: 4, c2: false, c3: new Date(), c4: 'hello!' },
{ c1: 5, c2: true, c3: new Date(), c4: 'hello.' },
{
c1: 1,
c2: true,
c3: '2025-08-15T09:48:14.259Z',
c4: 'iso 8601 date strings are okay too',
},
];
const result = await dataStoreService.insertRows(dataStoreId, project1.id, rows);
@@ -886,7 +892,7 @@ describe('dataStore', () => {
project1.id,
{},
);
expect(count).toEqual(3);
expect(count).toEqual(4);
expect(data).toEqual(
rows.map((row, i) => ({
...row,
@@ -998,6 +1004,26 @@ describe('dataStore', () => {
await expect(result).rejects.toThrow(new DataStoreValidationError('unknown column name'));
});
it('rejects a invalid date string to date column', async () => {
// ARRANGE
const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, {
name: 'dataStore',
columns: [{ name: 'c1', type: 'date' }],
});
// ACT
const result = dataStoreService.insertRows(dataStoreId, project1.id, [
{ c1: '2025-99-15T09:48:14.259Z' },
]);
// ASSERT
await expect(result).rejects.toThrow(
new DataStoreValidationError(
"value '2025-99-15T09:48:14.259Z' does not match column type 'date'",
),
);
});
it('rejects unknown data store id', async () => {
// ARRANGE
await dataStoreService.createDataStore(project1.id, {

View File

@@ -1,3 +1,4 @@
import { dateTimeSchema } from '@n8n/api-types';
import type {
AddDataStoreColumnDto,
CreateDataStoreDto,
@@ -172,29 +173,38 @@ export class DataStoreService {
if (cell === null) continue;
switch (columnTypeMap.get(key)) {
case 'boolean':
if (typeof cell !== 'boolean')
if (typeof cell !== 'boolean') {
throw new DataStoreValidationError(
`value '${cell.toString()}' does not match column type 'boolean'`,
);
}
break;
case 'date':
if (!(cell instanceof Date))
throw new DataStoreValidationError(
`value '${cell}' does not match column type 'date'`,
);
row[key] = cell.toISOString();
break;
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'`);
case 'string':
if (typeof cell !== 'string')
if (typeof cell !== 'string') {
throw new DataStoreValidationError(
`value '${cell.toString()}' does not match column type 'string'`,
);
}
break;
case 'number':
if (typeof cell !== 'number')
if (typeof cell !== 'number') {
throw new DataStoreValidationError(
`value '${cell.toString()}' does not match column type 'number'`,
);
}
break;
}
}