mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +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-class';
|
||||
|
||||
import { dataStoreColumnNameSchema } from '../../schemas/data-store.schema';
|
||||
|
||||
const dataStoreValueSchema = z.union([z.string(), z.number(), z.boolean(), z.date(), z.null()]);
|
||||
import {
|
||||
dataStoreColumnNameSchema,
|
||||
dataStoreColumnValueSchema,
|
||||
} from '../../schemas/data-store.schema';
|
||||
|
||||
const upsertDataStoreRowsShape = {
|
||||
rows: z.array(z.record(dataStoreValueSchema)),
|
||||
rows: z.array(z.record(dataStoreColumnNameSchema, dataStoreColumnValueSchema)),
|
||||
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 { 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 { ListDataStoreQueryDto } from './data-store/list-data-store-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))
|
||||
.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 const dataStoreColumnValueSchema = z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
z.date(),
|
||||
]);
|
||||
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
import type { Project, User } from '@n8n/db';
|
||||
import { ProjectRepository, QueryFailedError } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { createDataStore } from '@test-integration/db/data-stores';
|
||||
import { createOwner, createMember, createAdmin } from '@test-integration/db/users';
|
||||
import type { SuperAgentTest } from '@test-integration/types';
|
||||
import * as utils from '@test-integration/utils';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { DataStoreColumnRepository } from '../data-store-column.repository';
|
||||
import { DataStoreRowsRepository } from '../data-store-rows.repository';
|
||||
@@ -1834,9 +1835,12 @@ describe('POST /projects/:projectId/data-stores/:dataStoreId/insert', () => {
|
||||
.send(payload)
|
||||
.expect(200);
|
||||
|
||||
const rowsInDb = await dataStoreRowsRepository.getManyAndCount(toTableName(dataStore.id), {});
|
||||
expect(rowsInDb.count).toBe(1);
|
||||
expect(rowsInDb.data[0]).toMatchObject(payload.data[0]);
|
||||
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(payload.data[0]);
|
||||
});
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('retrieves rows correctly', async () => {
|
||||
// ARRANGE
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
addColumnQuery,
|
||||
deleteColumnQuery,
|
||||
buildInsertQuery,
|
||||
buildUpdateQuery,
|
||||
splitRowsByExistence,
|
||||
} 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', () => {
|
||||
it('should correctly separate rows into insert and update based on matchFields', () => {
|
||||
const existing = [
|
||||
|
||||
@@ -13,20 +13,21 @@ import {
|
||||
QueryRunner,
|
||||
SelectQueryBuilder,
|
||||
} from '@n8n/typeorm';
|
||||
import { DataStoreRows } from 'n8n-workflow';
|
||||
|
||||
import { DataStoreColumn } from './data-store-column.entity';
|
||||
import {
|
||||
addColumnQuery,
|
||||
buildColumnTypeMap,
|
||||
buildInsertQuery,
|
||||
buildUpdateQuery,
|
||||
deleteColumnQuery,
|
||||
getPlaceholder,
|
||||
normalizeValue,
|
||||
quoteIdentifier,
|
||||
splitRowsByExistence,
|
||||
toDslColumns,
|
||||
toTableName,
|
||||
} from './utils/sql-utils';
|
||||
import { DataStoreRows } from 'n8n-workflow';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type QueryBuilder = SelectQueryBuilder<any>;
|
||||
@@ -70,13 +71,8 @@ export class DataStoreRowsRepository {
|
||||
dto: UpsertDataStoreRowsDto,
|
||||
columns: DataStoreColumn[],
|
||||
) {
|
||||
const dbType = this.dataSource.options.type;
|
||||
const { rows, matchFields } = dto;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { rowsToInsert, rowsToUpdate } = await this.fetchAndSplitRowsByExistence(
|
||||
tableName,
|
||||
matchFields,
|
||||
@@ -89,15 +85,48 @@ export class DataStoreRowsRepository {
|
||||
|
||||
if (rowsToUpdate.length > 0) {
|
||||
for (const row of rowsToUpdate) {
|
||||
// TypeORM cannot infer the columns for a dynamic table name, so we use a raw query
|
||||
const [query, parameters] = buildUpdateQuery(tableName, row, columns, matchFields, dbType);
|
||||
await this.dataSource.query(query, parameters);
|
||||
const updateKeys = Object.keys(row).filter((key) => !matchFields.includes(key));
|
||||
if (updateKeys.length === 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
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[]) {
|
||||
if (ids.length === 0) {
|
||||
return true;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ListDataStoreQueryDto,
|
||||
MoveDataStoreColumnDto,
|
||||
UpdateDataStoreDto,
|
||||
UpdateDataStoreRowDto,
|
||||
UpsertDataStoreRowsDto,
|
||||
} from '@n8n/api-types';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
@@ -22,6 +23,11 @@ import {
|
||||
RestController,
|
||||
} 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 { DataStoreColumnNameConflictError } from './errors/data-store-column-name-conflict.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 { 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')
|
||||
export class DataStoreController {
|
||||
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')
|
||||
@ProjectScope('dataStore:writeRow')
|
||||
async deleteDataStoreRows(
|
||||
|
||||
@@ -7,9 +7,11 @@ import type {
|
||||
DataStoreListOptions,
|
||||
UpsertDataStoreRowsDto,
|
||||
UpdateDataStoreDto,
|
||||
UpdateDataStoreRowDto,
|
||||
} from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { DataStoreRow, DataStoreRows } from 'n8n-workflow';
|
||||
|
||||
import { DataStoreColumnRepository } from './data-store-column.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 { DataStoreValidationError } from './errors/data-store-validation.error';
|
||||
import { toTableName, normalizeRows } from './utils/sql-utils';
|
||||
import { DataStoreRows } from 'n8n-workflow';
|
||||
|
||||
@Service()
|
||||
export class DataStoreService {
|
||||
@@ -139,18 +140,78 @@ export class DataStoreService {
|
||||
await this.validateDataStoreExists(dataStoreId, projectId);
|
||||
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);
|
||||
|
||||
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[]) {
|
||||
await this.validateDataStoreExists(dataStoreId, projectId);
|
||||
|
||||
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);
|
||||
if (columns.length === 0) {
|
||||
throw new DataStoreValidationError(
|
||||
@@ -158,56 +219,49 @@ export class DataStoreService {
|
||||
);
|
||||
}
|
||||
|
||||
const columnNames = new Set(columns.map((x) => x.name));
|
||||
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;
|
||||
}
|
||||
this.validateRowsWithColumns(rows, columns, allowPartial, includeSystemColumns);
|
||||
}
|
||||
|
||||
throw new DataStoreValidationError(`value '${cell}' does not match column type 'date'`);
|
||||
case 'string':
|
||||
if (typeof cell !== 'string') {
|
||||
throw new DataStoreValidationError(
|
||||
`value '${cell.toString()}' does not match column type 'string'`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'number':
|
||||
if (typeof cell !== 'number') {
|
||||
throw new DataStoreValidationError(
|
||||
`value '${cell.toString()}' does not match column type 'number'`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
private validateCell(row: DataStoreRow, key: string, columnTypeMap: Map<string, string>) {
|
||||
const cell = row[key];
|
||||
if (cell === null) return;
|
||||
|
||||
const columnType = columnTypeMap.get(key);
|
||||
switch (columnType) {
|
||||
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'`);
|
||||
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];
|
||||
}
|
||||
|
||||
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(
|
||||
existing: Array<Record<string, unknown>>,
|
||||
matchFields: string[],
|
||||
@@ -251,7 +209,7 @@ export function normalizeRows(rows: DataStoreRows, columns: DataStoreColumn[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeValue(
|
||||
export function normalizeValue(
|
||||
value: unknown,
|
||||
columnType: string | undefined,
|
||||
dbType: DataSourceOptions['type'],
|
||||
@@ -282,7 +240,7 @@ export function getPlaceholder(index: number, dbType: DataSourceOptions['type'])
|
||||
return dbType.includes('postgres') ? `$${index}` : '?';
|
||||
}
|
||||
|
||||
function buildColumnTypeMap(
|
||||
export function buildColumnTypeMap(
|
||||
columns: Array<{ name: string; type: string }>,
|
||||
): Record<string, string> {
|
||||
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 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
|
||||
export interface IDataStoreProjectAggregateService {
|
||||
|
||||
Reference in New Issue
Block a user