mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(core): Add data store row update endpoint (no-changelog) (#18601)
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { Z } from 'zod-class';
|
||||||
|
|
||||||
|
import {
|
||||||
|
dataStoreColumnNameSchema,
|
||||||
|
dataStoreColumnValueSchema,
|
||||||
|
} from '../../schemas/data-store.schema';
|
||||||
|
|
||||||
|
const updateDataStoreRowShape = {
|
||||||
|
filter: z
|
||||||
|
.record(dataStoreColumnNameSchema, dataStoreColumnValueSchema)
|
||||||
|
.refine((obj) => Object.keys(obj).length > 0, {
|
||||||
|
message: 'filter must not be empty',
|
||||||
|
}),
|
||||||
|
data: z
|
||||||
|
.record(dataStoreColumnNameSchema, dataStoreColumnValueSchema)
|
||||||
|
.refine((obj) => Object.keys(obj).length > 0, {
|
||||||
|
message: 'data must not be empty',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export class UpdateDataStoreRowDto extends Z.class(updateDataStoreRowShape) {}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Z } from 'zod-class';
|
import { Z } from 'zod-class';
|
||||||
|
|
||||||
import { dataStoreColumnNameSchema } from '../../schemas/data-store.schema';
|
import {
|
||||||
|
dataStoreColumnNameSchema,
|
||||||
const dataStoreValueSchema = z.union([z.string(), z.number(), z.boolean(), z.date(), z.null()]);
|
dataStoreColumnValueSchema,
|
||||||
|
} from '../../schemas/data-store.schema';
|
||||||
|
|
||||||
const upsertDataStoreRowsShape = {
|
const upsertDataStoreRowsShape = {
|
||||||
rows: z.array(z.record(dataStoreValueSchema)),
|
rows: z.array(z.record(dataStoreColumnNameSchema, dataStoreColumnValueSchema)),
|
||||||
matchFields: z.array(dataStoreColumnNameSchema).min(1),
|
matchFields: z.array(dataStoreColumnNameSchema).min(1),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ 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 { 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';
|
||||||
|
|||||||
@@ -57,5 +57,10 @@ export const dateTimeSchema = z
|
|||||||
.transform((s) => new Date(s))
|
.transform((s) => new Date(s))
|
||||||
.pipe(z.date());
|
.pipe(z.date());
|
||||||
|
|
||||||
// Dates are received as date strings and validated before insertion
|
export const dataStoreColumnValueSchema = z.union([
|
||||||
export const dataStoreColumnValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
|
z.string(),
|
||||||
|
z.number(),
|
||||||
|
z.boolean(),
|
||||||
|
z.null(),
|
||||||
|
z.date(),
|
||||||
|
]);
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import {
|
|||||||
import type { Project, User } from '@n8n/db';
|
import type { Project, User } from '@n8n/db';
|
||||||
import { ProjectRepository, QueryFailedError } from '@n8n/db';
|
import { ProjectRepository, QueryFailedError } from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { createDataStore } from '@test-integration/db/data-stores';
|
import { createDataStore } from '@test-integration/db/data-stores';
|
||||||
import { createOwner, createMember, createAdmin } from '@test-integration/db/users';
|
import { createOwner, createMember, createAdmin } from '@test-integration/db/users';
|
||||||
import type { SuperAgentTest } from '@test-integration/types';
|
import type { SuperAgentTest } from '@test-integration/types';
|
||||||
import * as utils from '@test-integration/utils';
|
import * as utils from '@test-integration/utils';
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
|
|
||||||
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';
|
||||||
@@ -1834,9 +1835,12 @@ describe('POST /projects/:projectId/data-stores/:dataStoreId/insert', () => {
|
|||||||
.send(payload)
|
.send(payload)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
const rowsInDb = await dataStoreRowsRepository.getManyAndCount(toTableName(dataStore.id), {});
|
const readResponse = await authMemberAgent
|
||||||
expect(rowsInDb.count).toBe(1);
|
.get(`/projects/${project.id}/data-stores/${dataStore.id}/rows`)
|
||||||
expect(rowsInDb.data[0]).toMatchObject(payload.data[0]);
|
.expect(200);
|
||||||
|
|
||||||
|
expect(readResponse.body.data.count).toBe(1);
|
||||||
|
expect(readResponse.body.data.data[0]).toMatchObject(payload.data[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should insert rows if user has project:admin role in team project', async () => {
|
test('should insert rows if user has project:admin role in team project', async () => {
|
||||||
@@ -2759,3 +2763,467 @@ describe('POST /projects/:projectId/data-stores/:dataStoreId/upsert', () => {
|
|||||||
expect(rowsInDb.data[2]).toMatchObject(payload.rows[1]);
|
expect(rowsInDb.data[2]).toMatchObject(payload.rows[1]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('PATCH /projects/:projectId/data-stores/:dataStoreId/rows', () => {
|
||||||
|
test('should not update row when project does not exist', async () => {
|
||||||
|
const payload = {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { age: 31 },
|
||||||
|
};
|
||||||
|
|
||||||
|
await authOwnerAgent
|
||||||
|
.patch('/projects/non-existing-id/data-stores/some-data-store-id/rows')
|
||||||
|
.send(payload)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not update row when data store does not exist', async () => {
|
||||||
|
const project = await createTeamProject('test project', owner);
|
||||||
|
const payload = {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { age: 31 },
|
||||||
|
};
|
||||||
|
|
||||||
|
await authOwnerAgent
|
||||||
|
.patch(`/projects/${project.id}/data-stores/non-existing-id/rows`)
|
||||||
|
.send(payload)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not update row in another user's personal project data store", async () => {
|
||||||
|
const dataStore = await createDataStore(ownerProject, {
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
],
|
||||||
|
data: [{ name: 'Alice', age: 30 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { age: 31 },
|
||||||
|
};
|
||||||
|
|
||||||
|
await authMemberAgent
|
||||||
|
.patch(`/projects/${ownerProject.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.send(payload)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not update row if user has project:viewer role in team project', async () => {
|
||||||
|
const project = await createTeamProject('test project', owner);
|
||||||
|
await linkUserToProject(member, project, 'project:viewer');
|
||||||
|
const dataStore = await createDataStore(project, {
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
],
|
||||||
|
data: [{ name: 'Alice', age: 30 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { age: 31 },
|
||||||
|
};
|
||||||
|
|
||||||
|
await authMemberAgent
|
||||||
|
.patch(`/projects/${project.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.send(payload)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update row if user has project:editor role in team project', async () => {
|
||||||
|
const project = await createTeamProject('test project', owner);
|
||||||
|
await linkUserToProject(member, project, 'project:editor');
|
||||||
|
const dataStore = await createDataStore(project, {
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
],
|
||||||
|
data: [{ name: 'Alice', age: 30 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { age: 31 },
|
||||||
|
};
|
||||||
|
|
||||||
|
await authMemberAgent
|
||||||
|
.patch(`/projects/${project.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.send(payload)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const readResponse = await authMemberAgent
|
||||||
|
.get(`/projects/${project.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(readResponse.body.data.count).toBe(1);
|
||||||
|
expect(readResponse.body.data.data[0]).toMatchObject({ id: 1, name: 'Alice', age: 31 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update row if user has project:admin role in team project', async () => {
|
||||||
|
const project = await createTeamProject('test project', owner);
|
||||||
|
await linkUserToProject(admin, project, 'project:admin');
|
||||||
|
const dataStore = await createDataStore(project, {
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
],
|
||||||
|
data: [{ name: 'Alice', age: 30 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { age: 31 },
|
||||||
|
};
|
||||||
|
|
||||||
|
await authAdminAgent
|
||||||
|
.patch(`/projects/${project.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.send(payload)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const readResponse = await authAdminAgent
|
||||||
|
.get(`/projects/${project.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(readResponse.body.data.count).toBe(1);
|
||||||
|
expect(readResponse.body.data.data[0]).toMatchObject({ id: 1, name: 'Alice', age: 31 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update row if user is owner in team project', async () => {
|
||||||
|
const project = await createTeamProject('test project', owner);
|
||||||
|
const dataStore = await createDataStore(project, {
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
],
|
||||||
|
data: [{ name: 'Alice', age: 30 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { age: 31 },
|
||||||
|
};
|
||||||
|
|
||||||
|
await authOwnerAgent
|
||||||
|
.patch(`/projects/${project.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.send(payload)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const readResponse = await authOwnerAgent
|
||||||
|
.get(`/projects/${project.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(readResponse.body.data.count).toBe(1);
|
||||||
|
expect(readResponse.body.data.data[0]).toMatchObject({ id: 1, name: 'Alice', age: 31 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update row in personal project', async () => {
|
||||||
|
const dataStore = await createDataStore(memberProject, {
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
],
|
||||||
|
data: [{ name: 'Alice', age: 30 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { age: 31 },
|
||||||
|
};
|
||||||
|
|
||||||
|
await authMemberAgent
|
||||||
|
.patch(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.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({ id: 1, name: 'Alice', age: 31 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update row by id filter', async () => {
|
||||||
|
const dataStore = await createDataStore(memberProject, {
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
],
|
||||||
|
data: [
|
||||||
|
{ name: 'Alice', age: 30 },
|
||||||
|
{ name: 'Bob', age: 25 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: { id: 1 },
|
||||||
|
data: { age: 31 },
|
||||||
|
};
|
||||||
|
|
||||||
|
await authMemberAgent
|
||||||
|
.patch(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.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(2);
|
||||||
|
expect(readResponse.body.data.data).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ id: 1, name: 'Alice', age: 31 },
|
||||||
|
{ id: 2, name: 'Bob', age: 25 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update row with multiple filter conditions', async () => {
|
||||||
|
const dataStore = await createDataStore(memberProject, {
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
{ name: 'department', type: 'string' },
|
||||||
|
],
|
||||||
|
data: [
|
||||||
|
{ name: 'Alice', age: 30, department: 'Engineering' },
|
||||||
|
{ name: 'Alice', age: 25, department: 'Marketing' },
|
||||||
|
{ name: 'Bob', age: 30, department: 'Engineering' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: { name: 'Alice', age: 30 },
|
||||||
|
data: { department: 'Management' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await authMemberAgent
|
||||||
|
.patch(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.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(3);
|
||||||
|
expect(readResponse.body.data.data).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ id: 1, name: 'Alice', age: 30, department: 'Management' },
|
||||||
|
{ id: 2, name: 'Alice', age: 25, department: 'Marketing' },
|
||||||
|
{ id: 3, name: 'Bob', age: 30, department: 'Engineering' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return true when no rows match the filter', async () => {
|
||||||
|
const dataStore = await createDataStore(memberProject, {
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
],
|
||||||
|
data: [{ name: 'Alice', age: 30 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: { name: 'Charlie' },
|
||||||
|
data: { age: 25 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await authMemberAgent
|
||||||
|
.patch(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.send(payload)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.data).toBe(true);
|
||||||
|
|
||||||
|
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({ name: 'Alice', age: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fail when filter is empty', async () => {
|
||||||
|
const dataStore = await createDataStore(memberProject, {
|
||||||
|
columns: [{ name: 'name', type: 'string' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: {},
|
||||||
|
data: { name: 'Updated' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await authMemberAgent
|
||||||
|
.patch(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.send(payload)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.message).toContain('filter must not be empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fail when data is empty', async () => {
|
||||||
|
const dataStore = await createDataStore(memberProject, {
|
||||||
|
columns: [{ name: 'name', type: 'string' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await authMemberAgent
|
||||||
|
.patch(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.send(payload)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.message).toContain('data must not be empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fail when data contains invalid column names', async () => {
|
||||||
|
const dataStore = await createDataStore(memberProject, {
|
||||||
|
columns: [{ name: 'name', type: 'string' }],
|
||||||
|
data: [{ name: 'Alice' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { invalidColumn: 'value' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await authMemberAgent
|
||||||
|
.patch(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.send(payload)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.message).toContain('unknown column');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fail when filter contains invalid column names', async () => {
|
||||||
|
const dataStore = await createDataStore(memberProject, {
|
||||||
|
columns: [{ name: 'name', type: 'string' }],
|
||||||
|
data: [{ name: 'Alice' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: { invalidColumn: 'Alice' },
|
||||||
|
data: { name: 'Updated' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await authMemberAgent
|
||||||
|
.patch(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.send(payload)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.message).toContain('unknown column');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate data types in filter', async () => {
|
||||||
|
const dataStore = await createDataStore(memberProject, {
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
],
|
||||||
|
data: [{ name: 'Alice', age: 30 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: { age: 'invalid_number' },
|
||||||
|
data: { name: 'Updated' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await authMemberAgent
|
||||||
|
.patch(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.send(payload)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.message).toContain('does not match column type');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate data types in data', async () => {
|
||||||
|
const dataStore = await createDataStore(memberProject, {
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
],
|
||||||
|
data: [{ name: 'Alice', age: 30 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { age: 'invalid_number' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await authMemberAgent
|
||||||
|
.patch(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.send(payload)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.message).toContain('does not match column type');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow partial updates', async () => {
|
||||||
|
const dataStore = await createDataStore(memberProject, {
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
{ name: 'active', type: 'boolean' },
|
||||||
|
],
|
||||||
|
data: [{ name: 'Alice', age: 30, active: true }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { age: 31 }, // Only updating age, not name or active
|
||||||
|
};
|
||||||
|
|
||||||
|
await authMemberAgent
|
||||||
|
.patch(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.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({
|
||||||
|
name: 'Alice',
|
||||||
|
age: 31,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle date values in updates', async () => {
|
||||||
|
const dataStore = await createDataStore(memberProject, {
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'birthdate', type: 'date' },
|
||||||
|
],
|
||||||
|
data: [{ name: 'Alice', birthdate: '2000-01-01T00:00:00.000Z' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { birthdate: '1995-05-15T12:30:00.000Z' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await authMemberAgent
|
||||||
|
.patch(`/projects/${memberProject.id}/data-stores/${dataStore.id}/rows`)
|
||||||
|
.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({
|
||||||
|
name: 'Alice',
|
||||||
|
birthdate: '1995-05-15T12:30:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1323,6 +1323,315 @@ describe('dataStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('updateRow', () => {
|
||||||
|
it('should update an existing row with matching filter', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, {
|
||||||
|
name: 'dataStore',
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
{ name: 'active', type: 'boolean' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await dataStoreService.insertRows(dataStoreId, project1.id, [
|
||||||
|
{ name: 'Alice', age: 30, active: true },
|
||||||
|
{ name: 'Bob', age: 25, active: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { age: 31, active: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
const { data } = await dataStoreService.getManyRowsAndCount(dataStoreId, project1.id, {});
|
||||||
|
expect(data).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ id: 1, name: 'Alice', age: 31, active: false },
|
||||||
|
{ id: 2, name: 'Bob', age: 25, active: false },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to update by id', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, {
|
||||||
|
name: 'dataStore',
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
{ name: 'active', type: 'boolean' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await dataStoreService.insertRows(dataStoreId, project1.id, [
|
||||||
|
{ name: 'Alice', age: 30, active: true },
|
||||||
|
{ name: 'Bob', age: 25, active: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
|
||||||
|
filter: { id: 1 },
|
||||||
|
data: { name: 'Alicia', age: 31, active: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
const { data } = await dataStoreService.getManyRowsAndCount(dataStoreId, project1.id, {});
|
||||||
|
expect(data).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ id: 1, name: 'Alicia', age: 31, active: false },
|
||||||
|
{ id: 2, name: 'Bob', age: 25, active: false },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update row with multiple filter conditions', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, {
|
||||||
|
name: 'dataStore',
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
{ name: 'department', type: 'string' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await dataStoreService.insertRows(dataStoreId, project1.id, [
|
||||||
|
{ name: 'Alice', age: 30, department: 'Engineering' },
|
||||||
|
{ name: 'Alice', age: 25, department: 'Marketing' },
|
||||||
|
{ name: 'Bob', age: 30, department: 'Engineering' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
|
||||||
|
filter: { name: 'Alice', age: 30 },
|
||||||
|
data: { department: 'Management' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
const { data } = await dataStoreService.getManyRowsAndCount(dataStoreId, project1.id, {});
|
||||||
|
expect(data).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ id: 1, name: 'Alice', age: 30, department: 'Management' },
|
||||||
|
{ id: 2, name: 'Alice', age: 25, department: 'Marketing' },
|
||||||
|
{ id: 3, name: 'Bob', age: 30, department: 'Engineering' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when no rows match the filter', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, {
|
||||||
|
name: 'dataStore',
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await dataStoreService.insertRows(dataStoreId, project1.id, [{ name: 'Alice', age: 30 }]);
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
|
||||||
|
filter: { name: 'Charlie' },
|
||||||
|
data: { age: 25 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
const { data } = await dataStoreService.getManyRowsAndCount(dataStoreId, project1.id, {});
|
||||||
|
expect(data).toEqual([{ id: 1, name: 'Alice', age: 30 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw validation error when filters are empty', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, {
|
||||||
|
name: 'dataStore',
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await dataStoreService.insertRows(dataStoreId, project1.id, [{ name: 'Alice', age: 30 }]);
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const result = dataStoreService.updateRow(dataStoreId, project1.id, {
|
||||||
|
filter: {},
|
||||||
|
data: { name: 'Alice', age: 31 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
await expect(result).rejects.toThrow(
|
||||||
|
new DataStoreValidationError('Filter columns must not be empty for updateRow'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data } = await dataStoreService.getManyRowsAndCount(dataStoreId, project1.id, {});
|
||||||
|
expect(data).toEqual([{ id: 1, name: 'Alice', age: 30 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw validation error when data is empty', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, {
|
||||||
|
name: 'dataStore',
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await dataStoreService.insertRows(dataStoreId, project1.id, [{ name: 'Alice', age: 30 }]);
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const result = dataStoreService.updateRow(dataStoreId, project1.id, {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
await expect(result).rejects.toThrow(
|
||||||
|
new DataStoreValidationError('Data columns must not be empty for updateRow'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data } = await dataStoreService.getManyRowsAndCount(dataStoreId, project1.id, {});
|
||||||
|
expect(data).toEqual([{ id: 1, name: 'Alice', age: 30 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail when data store does not exist', async () => {
|
||||||
|
// ACT & ASSERT
|
||||||
|
const result = dataStoreService.updateRow('non-existent-id', project1.id, {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { age: 25 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(result).rejects.toThrow(DataStoreNotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail when data 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 & ASSERT
|
||||||
|
const result = dataStoreService.updateRow(dataStoreId, project1.id, {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { invalidColumn: 'value' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(result).rejects.toThrow(DataStoreValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 & ASSERT
|
||||||
|
const result = dataStoreService.updateRow(dataStoreId, project1.id, {
|
||||||
|
filter: { invalidColumn: 'Alice' },
|
||||||
|
data: { name: 'Bob' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(result).rejects.toThrow(DataStoreValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail when data contains invalid type values', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, {
|
||||||
|
name: 'dataStore',
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await dataStoreService.insertRows(dataStoreId, project1.id, [{ name: 'Alice', age: 30 }]);
|
||||||
|
|
||||||
|
// ACT & ASSERT
|
||||||
|
const result = dataStoreService.updateRow(dataStoreId, project1.id, {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { age: 'not-a-number' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(result).rejects.toThrow(DataStoreValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow partial data updates', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, {
|
||||||
|
name: 'dataStore',
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'age', type: 'number' },
|
||||||
|
{ name: 'active', type: 'boolean' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await dataStoreService.insertRows(dataStoreId, project1.id, [
|
||||||
|
{ name: 'Alice', age: 30, active: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ACT - only update age, leaving name and active unchanged
|
||||||
|
const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { age: 31 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
const { data } = await dataStoreService.getManyRowsAndCount(dataStoreId, project1.id, {});
|
||||||
|
expect(data).toEqual([{ id: 1, name: 'Alice', age: 31, active: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle date column updates correctly', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const { id: dataStoreId } = await dataStoreService.createDataStore(project1.id, {
|
||||||
|
name: 'dataStore',
|
||||||
|
columns: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'birthDate', type: 'date' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialDate = new Date('1990-01-01');
|
||||||
|
await dataStoreService.insertRows(dataStoreId, project1.id, [
|
||||||
|
{ name: 'Alice', birthDate: initialDate },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const newDate = new Date('1991-02-02');
|
||||||
|
const result = await dataStoreService.updateRow(dataStoreId, project1.id, {
|
||||||
|
filter: { name: 'Alice' },
|
||||||
|
data: { birthDate: newDate.toISOString() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
const { data } = await dataStoreService.getManyRowsAndCount(dataStoreId, project1.id, {});
|
||||||
|
expect(data).toEqual([{ id: 1, name: 'Alice', birthDate: newDate.toISOString() }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getManyRowsAndCount', () => {
|
describe('getManyRowsAndCount', () => {
|
||||||
it('retrieves rows correctly', async () => {
|
it('retrieves rows correctly', async () => {
|
||||||
// ARRANGE
|
// ARRANGE
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
addColumnQuery,
|
addColumnQuery,
|
||||||
deleteColumnQuery,
|
deleteColumnQuery,
|
||||||
buildInsertQuery,
|
buildInsertQuery,
|
||||||
buildUpdateQuery,
|
|
||||||
splitRowsByExistence,
|
splitRowsByExistence,
|
||||||
} from '../utils/sql-utils';
|
} from '../utils/sql-utils';
|
||||||
|
|
||||||
@@ -113,79 +112,6 @@ describe('sql-utils', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildUpdateQuery', () => {
|
|
||||||
it('should generate a valid SQL update query with one match field', () => {
|
|
||||||
const tableName = 'data_store_user_abc';
|
|
||||||
const row = { name: 'Alice', age: 30, city: 'Paris' };
|
|
||||||
const columns = [
|
|
||||||
{ id: 1, name: 'name', type: 'string' },
|
|
||||||
{ id: 2, name: 'age', type: 'number' },
|
|
||||||
{ id: 3, name: 'city', type: 'string' },
|
|
||||||
];
|
|
||||||
const matchFields = ['name'];
|
|
||||||
|
|
||||||
const [query, parameters] = buildUpdateQuery(tableName, row, columns, matchFields);
|
|
||||||
|
|
||||||
expect(query).toBe('UPDATE "data_store_user_abc" SET "age" = ?, "city" = ? WHERE "name" = ?');
|
|
||||||
expect(parameters).toEqual([30, 'Paris', 'Alice']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should generate a valid SQL update query with multiple match fields', () => {
|
|
||||||
const tableName = 'data_store_user_abc';
|
|
||||||
const row = { name: 'Alice', age: 30, city: 'Paris' };
|
|
||||||
const columns = [
|
|
||||||
{ id: 1, name: 'name', type: 'string' },
|
|
||||||
{ id: 2, name: 'age', type: 'number' },
|
|
||||||
{ id: 3, name: 'city', type: 'string' },
|
|
||||||
];
|
|
||||||
const matchFields = ['name', 'city'];
|
|
||||||
|
|
||||||
const [query, parameters] = buildUpdateQuery(tableName, row, columns, matchFields);
|
|
||||||
|
|
||||||
expect(query).toBe(
|
|
||||||
'UPDATE "data_store_user_abc" SET "age" = ? WHERE "name" = ? AND "city" = ?',
|
|
||||||
);
|
|
||||||
expect(parameters).toEqual([30, 'Alice', 'Paris']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty query and parameters if row is empty', () => {
|
|
||||||
const tableName = 'data_store_user_abc';
|
|
||||||
const row = {};
|
|
||||||
const matchFields = ['id'];
|
|
||||||
|
|
||||||
const [query, parameters] = buildUpdateQuery(tableName, row, [], matchFields);
|
|
||||||
|
|
||||||
expect(query).toBe('');
|
|
||||||
expect(parameters).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty query and parameters if matchFields is empty', () => {
|
|
||||||
const tableName = 'data_store_user_abc';
|
|
||||||
const row = { name: 'Alice', age: 30 };
|
|
||||||
const columns = [
|
|
||||||
{ id: 1, name: 'name', type: 'string' },
|
|
||||||
{ id: 2, name: 'age', type: 'number' },
|
|
||||||
];
|
|
||||||
const matchFields: string[] = [];
|
|
||||||
|
|
||||||
const [query, parameters] = buildUpdateQuery(tableName, row, columns, matchFields);
|
|
||||||
|
|
||||||
expect(query).toBe('');
|
|
||||||
expect(parameters).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty query and parameters if only matchFields are present in row', () => {
|
|
||||||
const tableName = 'data_store_user_abc';
|
|
||||||
const row = { id: 1 };
|
|
||||||
const matchFields = ['id'];
|
|
||||||
|
|
||||||
const [query, parameters] = buildUpdateQuery(tableName, row, [], matchFields);
|
|
||||||
|
|
||||||
expect(query).toBe('');
|
|
||||||
expect(parameters).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('splitRowsByExistence', () => {
|
describe('splitRowsByExistence', () => {
|
||||||
it('should correctly separate rows into insert and update based on matchFields', () => {
|
it('should correctly separate rows into insert and update based on matchFields', () => {
|
||||||
const existing = [
|
const existing = [
|
||||||
|
|||||||
@@ -13,20 +13,21 @@ import {
|
|||||||
QueryRunner,
|
QueryRunner,
|
||||||
SelectQueryBuilder,
|
SelectQueryBuilder,
|
||||||
} from '@n8n/typeorm';
|
} from '@n8n/typeorm';
|
||||||
|
import { DataStoreRows } from 'n8n-workflow';
|
||||||
|
|
||||||
import { DataStoreColumn } from './data-store-column.entity';
|
import { DataStoreColumn } from './data-store-column.entity';
|
||||||
import {
|
import {
|
||||||
addColumnQuery,
|
addColumnQuery,
|
||||||
|
buildColumnTypeMap,
|
||||||
buildInsertQuery,
|
buildInsertQuery,
|
||||||
buildUpdateQuery,
|
|
||||||
deleteColumnQuery,
|
deleteColumnQuery,
|
||||||
getPlaceholder,
|
getPlaceholder,
|
||||||
|
normalizeValue,
|
||||||
quoteIdentifier,
|
quoteIdentifier,
|
||||||
splitRowsByExistence,
|
splitRowsByExistence,
|
||||||
toDslColumns,
|
toDslColumns,
|
||||||
toTableName,
|
toTableName,
|
||||||
} from './utils/sql-utils';
|
} from './utils/sql-utils';
|
||||||
import { DataStoreRows } from 'n8n-workflow';
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type QueryBuilder = SelectQueryBuilder<any>;
|
type QueryBuilder = SelectQueryBuilder<any>;
|
||||||
@@ -70,13 +71,8 @@ export class DataStoreRowsRepository {
|
|||||||
dto: UpsertDataStoreRowsDto,
|
dto: UpsertDataStoreRowsDto,
|
||||||
columns: DataStoreColumn[],
|
columns: DataStoreColumn[],
|
||||||
) {
|
) {
|
||||||
const dbType = this.dataSource.options.type;
|
|
||||||
const { rows, matchFields } = dto;
|
const { rows, matchFields } = dto;
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { rowsToInsert, rowsToUpdate } = await this.fetchAndSplitRowsByExistence(
|
const { rowsToInsert, rowsToUpdate } = await this.fetchAndSplitRowsByExistence(
|
||||||
tableName,
|
tableName,
|
||||||
matchFields,
|
matchFields,
|
||||||
@@ -89,15 +85,48 @@ export class DataStoreRowsRepository {
|
|||||||
|
|
||||||
if (rowsToUpdate.length > 0) {
|
if (rowsToUpdate.length > 0) {
|
||||||
for (const row of rowsToUpdate) {
|
for (const row of rowsToUpdate) {
|
||||||
// TypeORM cannot infer the columns for a dynamic table name, so we use a raw query
|
const updateKeys = Object.keys(row).filter((key) => !matchFields.includes(key));
|
||||||
const [query, parameters] = buildUpdateQuery(tableName, row, columns, matchFields, dbType);
|
if (updateKeys.length === 0) {
|
||||||
await this.dataSource.query(query, parameters);
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setData = Object.fromEntries(updateKeys.map((key) => [key, row[key]]));
|
||||||
|
const whereData = Object.fromEntries(matchFields.map((key) => [key, row[key]]));
|
||||||
|
|
||||||
|
await this.updateRow(tableName, setData, whereData, columns);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateRow(
|
||||||
|
tableName: DataStoreUserTableName,
|
||||||
|
setData: Record<string, unknown>,
|
||||||
|
whereData: Record<string, unknown>,
|
||||||
|
columns: DataStoreColumn[],
|
||||||
|
) {
|
||||||
|
const dbType = this.dataSource.options.type;
|
||||||
|
const columnTypeMap = buildColumnTypeMap(columns);
|
||||||
|
|
||||||
|
const queryBuilder = this.dataSource.createQueryBuilder().update(tableName);
|
||||||
|
|
||||||
|
const setValues: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(setData)) {
|
||||||
|
setValues[key] = normalizeValue(value, columnTypeMap[key], dbType);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryBuilder.set(setValues);
|
||||||
|
|
||||||
|
const normalizedWhereData: Record<string, unknown> = {};
|
||||||
|
for (const [field, value] of Object.entries(whereData)) {
|
||||||
|
normalizedWhereData[field] = normalizeValue(value, columnTypeMap[field], dbType);
|
||||||
|
}
|
||||||
|
queryBuilder.where(normalizedWhereData);
|
||||||
|
|
||||||
|
await queryBuilder.execute();
|
||||||
|
}
|
||||||
|
|
||||||
async deleteRows(tableName: DataStoreUserTableName, ids: number[]) {
|
async deleteRows(tableName: DataStoreUserTableName, ids: number[]) {
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ListDataStoreQueryDto,
|
ListDataStoreQueryDto,
|
||||||
MoveDataStoreColumnDto,
|
MoveDataStoreColumnDto,
|
||||||
UpdateDataStoreDto,
|
UpdateDataStoreDto,
|
||||||
|
UpdateDataStoreRowDto,
|
||||||
UpsertDataStoreRowsDto,
|
UpsertDataStoreRowsDto,
|
||||||
} from '@n8n/api-types';
|
} from '@n8n/api-types';
|
||||||
import { AuthenticatedRequest } from '@n8n/db';
|
import { AuthenticatedRequest } from '@n8n/db';
|
||||||
@@ -22,6 +23,11 @@ import {
|
|||||||
RestController,
|
RestController,
|
||||||
} from '@n8n/decorators';
|
} from '@n8n/decorators';
|
||||||
|
|
||||||
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
|
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||||
|
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||||
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||||
|
|
||||||
import { DataStoreService } from './data-store.service';
|
import { DataStoreService } from './data-store.service';
|
||||||
import { DataStoreColumnNameConflictError } from './errors/data-store-column-name-conflict.error';
|
import { DataStoreColumnNameConflictError } from './errors/data-store-column-name-conflict.error';
|
||||||
import { DataStoreColumnNotFoundError } from './errors/data-store-column-not-found.error';
|
import { DataStoreColumnNotFoundError } from './errors/data-store-column-not-found.error';
|
||||||
@@ -29,11 +35,6 @@ import { DataStoreNameConflictError } from './errors/data-store-name-conflict.er
|
|||||||
import { DataStoreNotFoundError } from './errors/data-store-not-found.error';
|
import { DataStoreNotFoundError } from './errors/data-store-not-found.error';
|
||||||
import { DataStoreValidationError } from './errors/data-store-validation.error';
|
import { DataStoreValidationError } from './errors/data-store-validation.error';
|
||||||
|
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
|
||||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
|
||||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
|
||||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
|
||||||
|
|
||||||
@RestController('/projects/:projectId/data-stores')
|
@RestController('/projects/:projectId/data-stores')
|
||||||
export class DataStoreController {
|
export class DataStoreController {
|
||||||
constructor(private readonly dataStoreService: DataStoreService) {}
|
constructor(private readonly dataStoreService: DataStoreService) {}
|
||||||
@@ -279,6 +280,29 @@ export class DataStoreController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('/:dataStoreId/rows')
|
||||||
|
@ProjectScope('dataStore:writeRow')
|
||||||
|
async updateDataStoreRow(
|
||||||
|
req: AuthenticatedRequest<{ projectId: string }>,
|
||||||
|
_res: Response,
|
||||||
|
@Param('dataStoreId') dataStoreId: string,
|
||||||
|
@Body dto: UpdateDataStoreRowDto,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await this.dataStoreService.updateRow(dataStoreId, req.params.projectId, dto);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof DataStoreNotFoundError) {
|
||||||
|
throw new NotFoundError(e.message);
|
||||||
|
} else if (e instanceof DataStoreValidationError) {
|
||||||
|
throw new BadRequestError(e.message);
|
||||||
|
} else if (e instanceof Error) {
|
||||||
|
throw new InternalServerError(e.message, e);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('/:dataStoreId/rows')
|
@Delete('/:dataStoreId/rows')
|
||||||
@ProjectScope('dataStore:writeRow')
|
@ProjectScope('dataStore:writeRow')
|
||||||
async deleteDataStoreRows(
|
async deleteDataStoreRows(
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import type {
|
|||||||
DataStoreListOptions,
|
DataStoreListOptions,
|
||||||
UpsertDataStoreRowsDto,
|
UpsertDataStoreRowsDto,
|
||||||
UpdateDataStoreDto,
|
UpdateDataStoreDto,
|
||||||
|
UpdateDataStoreRowDto,
|
||||||
} 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, DataStoreRows } 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';
|
||||||
@@ -19,7 +21,6 @@ import { DataStoreNameConflictError } from './errors/data-store-name-conflict.er
|
|||||||
import { DataStoreNotFoundError } from './errors/data-store-not-found.error';
|
import { DataStoreNotFoundError } from './errors/data-store-not-found.error';
|
||||||
import { DataStoreValidationError } from './errors/data-store-validation.error';
|
import { DataStoreValidationError } from './errors/data-store-validation.error';
|
||||||
import { toTableName, normalizeRows } from './utils/sql-utils';
|
import { toTableName, normalizeRows } from './utils/sql-utils';
|
||||||
import { DataStoreRows } from 'n8n-workflow';
|
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class DataStoreService {
|
export class DataStoreService {
|
||||||
@@ -139,18 +140,78 @@ export class DataStoreService {
|
|||||||
await this.validateDataStoreExists(dataStoreId, projectId);
|
await this.validateDataStoreExists(dataStoreId, projectId);
|
||||||
await this.validateRows(dataStoreId, dto.rows);
|
await this.validateRows(dataStoreId, dto.rows);
|
||||||
|
|
||||||
|
if (dto.rows.length === 0) {
|
||||||
|
throw new DataStoreValidationError('No rows provided for upsertRows');
|
||||||
|
}
|
||||||
|
|
||||||
const columns = await this.dataStoreColumnRepository.getColumns(dataStoreId);
|
const columns = await this.dataStoreColumnRepository.getColumns(dataStoreId);
|
||||||
|
|
||||||
return await this.dataStoreRowsRepository.upsertRows(toTableName(dataStoreId), dto, columns);
|
return await this.dataStoreRowsRepository.upsertRows(toTableName(dataStoreId), dto, columns);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateRow(dataStoreId: string, projectId: string, dto: UpdateDataStoreRowDto) {
|
||||||
|
await this.validateDataStoreExists(dataStoreId, projectId);
|
||||||
|
|
||||||
|
const columns = await this.dataStoreColumnRepository.getColumns(dataStoreId);
|
||||||
|
if (columns.length === 0) {
|
||||||
|
throw new DataStoreValidationError(
|
||||||
|
'No columns found for this data store or data store not found',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, filter } = dto;
|
||||||
|
if (!filter || Object.keys(filter).length === 0) {
|
||||||
|
throw new DataStoreValidationError('Filter columns must not be empty for updateRow');
|
||||||
|
}
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
throw new DataStoreValidationError('Data columns must not be empty for updateRow');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validateRowsWithColumns([filter], columns, true, true);
|
||||||
|
this.validateRowsWithColumns([data], columns, true, false);
|
||||||
|
|
||||||
|
await this.dataStoreRowsRepository.updateRow(toTableName(dataStoreId), data, filter, columns);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async deleteRows(dataStoreId: string, projectId: string, ids: number[]) {
|
async deleteRows(dataStoreId: string, projectId: string, ids: number[]) {
|
||||||
await this.validateDataStoreExists(dataStoreId, projectId);
|
await this.validateDataStoreExists(dataStoreId, projectId);
|
||||||
|
|
||||||
return await this.dataStoreRowsRepository.deleteRows(toTableName(dataStoreId), ids);
|
return await this.dataStoreRowsRepository.deleteRows(toTableName(dataStoreId), ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateRows(dataStoreId: string, rows: DataStoreRows): Promise<void> {
|
private validateRowsWithColumns(
|
||||||
|
rows: DataStoreRows,
|
||||||
|
columns: Array<{ name: string; type: string }>,
|
||||||
|
allowPartial = false,
|
||||||
|
includeSystemColumns = false,
|
||||||
|
): void {
|
||||||
|
// Include system columns like 'id' if requested
|
||||||
|
const allColumns = includeSystemColumns
|
||||||
|
? [{ name: 'id', type: 'number' }, ...columns]
|
||||||
|
: columns;
|
||||||
|
const columnNames = new Set(allColumns.map((x) => x.name));
|
||||||
|
const columnTypeMap = new Map(allColumns.map((x) => [x.name, x.type]));
|
||||||
|
for (const row of rows) {
|
||||||
|
const keys = Object.keys(row);
|
||||||
|
if (!allowPartial && columnNames.size !== keys.length) {
|
||||||
|
throw new DataStoreValidationError('mismatched key count');
|
||||||
|
}
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!columnNames.has(key)) {
|
||||||
|
throw new DataStoreValidationError('unknown column name');
|
||||||
|
}
|
||||||
|
this.validateCell(row, key, columnTypeMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateRows(
|
||||||
|
dataStoreId: string,
|
||||||
|
rows: DataStoreRows,
|
||||||
|
allowPartial = false,
|
||||||
|
includeSystemColumns = false,
|
||||||
|
): Promise<void> {
|
||||||
const columns = await this.dataStoreColumnRepository.getColumns(dataStoreId);
|
const columns = await this.dataStoreColumnRepository.getColumns(dataStoreId);
|
||||||
if (columns.length === 0) {
|
if (columns.length === 0) {
|
||||||
throw new DataStoreValidationError(
|
throw new DataStoreValidationError(
|
||||||
@@ -158,56 +219,49 @@ export class DataStoreService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnNames = new Set(columns.map((x) => x.name));
|
this.validateRowsWithColumns(rows, columns, allowPartial, includeSystemColumns);
|
||||||
const columnTypeMap = new Map(columns.map((x) => [x.name, x.type]));
|
}
|
||||||
for (const row of rows) {
|
|
||||||
const keys = Object.keys(row);
|
|
||||||
if (columns.length !== keys.length) {
|
|
||||||
throw new DataStoreValidationError('mismatched key count');
|
|
||||||
}
|
|
||||||
for (const key of keys) {
|
|
||||||
if (!columnNames.has(key)) {
|
|
||||||
throw new DataStoreValidationError('unknown column name');
|
|
||||||
}
|
|
||||||
const cell = row[key];
|
|
||||||
if (cell === null) continue;
|
|
||||||
switch (columnTypeMap.get(key)) {
|
|
||||||
case 'boolean':
|
|
||||||
if (typeof cell !== 'boolean') {
|
|
||||||
throw new DataStoreValidationError(
|
|
||||||
`value '${cell.toString()}' 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'`);
|
private validateCell(row: DataStoreRow, key: string, columnTypeMap: Map<string, string>) {
|
||||||
case 'string':
|
const cell = row[key];
|
||||||
if (typeof cell !== 'string') {
|
if (cell === null) return;
|
||||||
throw new DataStoreValidationError(
|
|
||||||
`value '${cell.toString()}' does not match column type 'string'`,
|
const columnType = columnTypeMap.get(key);
|
||||||
);
|
switch (columnType) {
|
||||||
}
|
case 'boolean':
|
||||||
break;
|
if (typeof cell !== 'boolean') {
|
||||||
case 'number':
|
throw new DataStoreValidationError(
|
||||||
if (typeof cell !== 'number') {
|
`value '${String(cell)}' does not match column type 'boolean'`,
|
||||||
throw new DataStoreValidationError(
|
);
|
||||||
`value '${cell.toString()}' does not match column type 'number'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
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'`);
|
||||||
|
case 'string':
|
||||||
|
if (typeof cell !== 'string') {
|
||||||
|
throw new DataStoreValidationError(
|
||||||
|
`value '${String(cell)}' does not match column type 'string'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'number':
|
||||||
|
if (typeof cell !== 'number') {
|
||||||
|
throw new DataStoreValidationError(
|
||||||
|
`value '${String(cell)}' does not match column type 'number'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,48 +129,6 @@ export function buildInsertQuery(
|
|||||||
return [query, parameters];
|
return [query, parameters];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildUpdateQuery(
|
|
||||||
tableName: DataStoreUserTableName,
|
|
||||||
row: Record<string, unknown>,
|
|
||||||
columns: Array<{ name: string; type: string }>,
|
|
||||||
matchFields: string[],
|
|
||||||
dbType: DataSourceOptions['type'] = 'sqlite',
|
|
||||||
): [string, unknown[]] {
|
|
||||||
if (Object.keys(row).length === 0 || matchFields.length === 0) {
|
|
||||||
return ['', []];
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateKeys = Object.keys(row).filter((key) => !matchFields.includes(key));
|
|
||||||
if (updateKeys.length === 0) {
|
|
||||||
return ['', []];
|
|
||||||
}
|
|
||||||
|
|
||||||
const quotedTableName = quoteIdentifier(tableName, dbType);
|
|
||||||
const columnTypeMap = buildColumnTypeMap(columns);
|
|
||||||
|
|
||||||
const parameters: unknown[] = [];
|
|
||||||
let placeholderIndex = 1;
|
|
||||||
|
|
||||||
const setClause = updateKeys
|
|
||||||
.map((key) => {
|
|
||||||
const value = normalizeValue(row[key], columnTypeMap[key], dbType);
|
|
||||||
parameters.push(value);
|
|
||||||
return `${quoteIdentifier(key, dbType)} = ${getPlaceholder(placeholderIndex++, dbType)}`;
|
|
||||||
})
|
|
||||||
.join(', ');
|
|
||||||
|
|
||||||
const whereClause = matchFields
|
|
||||||
.map((key) => {
|
|
||||||
const value = normalizeValue(row[key], columnTypeMap[key], dbType);
|
|
||||||
parameters.push(value);
|
|
||||||
return `${quoteIdentifier(key, dbType)} = ${getPlaceholder(placeholderIndex++, dbType)}`;
|
|
||||||
})
|
|
||||||
.join(' AND ');
|
|
||||||
|
|
||||||
const query = `UPDATE ${quotedTableName} SET ${setClause} WHERE ${whereClause}`;
|
|
||||||
return [query, parameters];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function splitRowsByExistence(
|
export function splitRowsByExistence(
|
||||||
existing: Array<Record<string, unknown>>,
|
existing: Array<Record<string, unknown>>,
|
||||||
matchFields: string[],
|
matchFields: string[],
|
||||||
@@ -251,7 +209,7 @@ export function normalizeRows(rows: DataStoreRows, columns: DataStoreColumn[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeValue(
|
export function normalizeValue(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
columnType: string | undefined,
|
columnType: string | undefined,
|
||||||
dbType: DataSourceOptions['type'],
|
dbType: DataSourceOptions['type'],
|
||||||
@@ -282,7 +240,7 @@ export function getPlaceholder(index: number, dbType: DataSourceOptions['type'])
|
|||||||
return dbType.includes('postgres') ? `$${index}` : '?';
|
return dbType.includes('postgres') ? `$${index}` : '?';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildColumnTypeMap(
|
export function buildColumnTypeMap(
|
||||||
columns: Array<{ name: string; type: string }>,
|
columns: Array<{ name: string; type: string }>,
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
return Object.fromEntries(columns.map((col) => [col.name, col.type]));
|
return Object.fromEntries(columns.map((col) => [col.name, col.type]));
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ export type AddDataStoreColumnOptions = Pick<DataStoreColumn, 'name' | 'type'> &
|
|||||||
|
|
||||||
export type DataStoreColumnJsType = string | number | boolean | Date;
|
export type DataStoreColumnJsType = string | number | boolean | Date;
|
||||||
|
|
||||||
export type DataStoreRows = Array<Record<string, DataStoreColumnJsType | null>>;
|
export type DataStoreRow = Record<string, DataStoreColumnJsType | null>;
|
||||||
|
|
||||||
|
export type DataStoreRows = DataStoreRow[];
|
||||||
|
|
||||||
// APIs for a data store service operating on a specific projectId
|
// APIs for a data store service operating on a specific projectId
|
||||||
export interface IDataStoreProjectAggregateService {
|
export interface IDataStoreProjectAggregateService {
|
||||||
|
|||||||
Reference in New Issue
Block a user