diff --git a/packages/@n8n/api-types/src/dto/data-store/add-data-store-column.dto.ts b/packages/@n8n/api-types/src/dto/data-store/add-data-store-column.dto.ts index b72b7ed2499..37e089b4d53 100644 --- a/packages/@n8n/api-types/src/dto/data-store/add-data-store-column.dto.ts +++ b/packages/@n8n/api-types/src/dto/data-store/add-data-store-column.dto.ts @@ -2,6 +2,4 @@ import { Z } from 'zod-class'; import { dataStoreCreateColumnSchema } from '../../schemas/data-store.schema'; -export class AddDataStoreColumnDto extends Z.class({ - column: dataStoreCreateColumnSchema, -}) {} +export class AddDataStoreColumnDto extends Z.class(dataStoreCreateColumnSchema.shape) {} diff --git a/packages/@n8n/api-types/src/dto/data-store/list-data-store-content-query.dto.ts b/packages/@n8n/api-types/src/dto/data-store/list-data-store-content-query.dto.ts index 7f6e8cba753..2d49953402b 100644 --- a/packages/@n8n/api-types/src/dto/data-store/list-data-store-content-query.dto.ts +++ b/packages/@n8n/api-types/src/dto/data-store/list-data-store-content-query.dto.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { Z } from 'zod-class'; import { dataStoreColumnNameSchema } from '../../schemas/data-store.schema'; -import { paginationSchema } from 'dto/pagination/pagination.dto'; +import { paginationSchema } from '../pagination/pagination.dto'; const FilterConditionSchema = z.union([z.literal('eq'), z.literal('neq')]); export type ListDataStoreContentFilterConditionType = z.infer; diff --git a/packages/@n8n/api-types/src/dto/data-store/list-data-store-query.dto.ts b/packages/@n8n/api-types/src/dto/data-store/list-data-store-query.dto.ts index eb82bd46c91..48698cdabbb 100644 --- a/packages/@n8n/api-types/src/dto/data-store/list-data-store-query.dto.ts +++ b/packages/@n8n/api-types/src/dto/data-store/list-data-store-query.dto.ts @@ -1,8 +1,9 @@ -import { paginationSchema } from 'dto/pagination/pagination.dto'; import { jsonParse } from 'n8n-workflow'; import { z } from 'zod'; import { Z } from 'zod-class'; +import { paginationSchema } from '../pagination/pagination.dto'; + const VALID_SORT_OPTIONS = [ 'name:asc', 'name:desc', diff --git a/packages/@n8n/api-types/src/schemas/data-store.schema.ts b/packages/@n8n/api-types/src/schemas/data-store.schema.ts index ae5da8d8ab7..b7b7de896c4 100644 --- a/packages/@n8n/api-types/src/schemas/data-store.schema.ts +++ b/packages/@n8n/api-types/src/schemas/data-store.schema.ts @@ -19,6 +19,7 @@ export const dataStoreColumnTypeSchema = z.enum(['string', 'number', 'boolean', export const dataStoreCreateColumnSchema = z.object({ name: dataStoreColumnNameSchema, type: dataStoreColumnTypeSchema, + columnIndex: z.number().optional(), }); export type DataStoreCreateColumnSchema = z.infer; diff --git a/packages/@n8n/db/src/migrations/common/1747814180618-CreateDataStoreTables.ts b/packages/@n8n/db/src/migrations/common/1747814180618-CreateDataStoreTables.ts index c47ef738dbe..361181d11e1 100644 --- a/packages/@n8n/db/src/migrations/common/1747814180618-CreateDataStoreTables.ts +++ b/packages/@n8n/db/src/migrations/common/1747814180618-CreateDataStoreTables.ts @@ -24,6 +24,7 @@ export class CreateDataStoreTables1747814180618 implements ReversibleMigration { column('id').varchar(36).primary.notNull, column('name').varchar(128).notNull, column('type').varchar(32).notNull, + column('columnIndex').int.notNull, column('dataStoreId').varchar(36).notNull, ) .withForeignKey('dataStoreId', { diff --git a/packages/cli/src/modules/data-store/__tests__/data-store.service.test.ts b/packages/cli/src/modules/data-store/__tests__/data-store.service.test.ts index adb6a0daa1d..fb0a4007d9c 100644 --- a/packages/cli/src/modules/data-store/__tests__/data-store.service.test.ts +++ b/packages/cli/src/modules/data-store/__tests__/data-store.service.test.ts @@ -154,15 +154,14 @@ describe('dataStore', () => { it('should fail with adding two columns of the same name', async () => { // ARRANGE await dataStoreService.addColumn(dataStore1.id, { - column: { name: 'myColumn1', type: 'string' }, + name: 'myColumn1', + type: 'string', }); // ACT const result = await dataStoreService.addColumn(dataStore1.id, { - column: { - name: 'myColumn1', - type: 'number', - }, + name: 'myColumn1', + type: 'number', }); // ASSERT @@ -172,10 +171,8 @@ describe('dataStore', () => { it('should fail with adding column of non-existent table', async () => { // ACT const result = await dataStoreService.addColumn('this is not an id', { - column: { - name: 'myColumn1', - type: 'number', - }, + name: 'myColumn1', + type: 'number', }); // ASSERT @@ -186,7 +183,8 @@ describe('dataStore', () => { it('should succeed with deleting a column', async () => { // ARRANGE await dataStoreService.addColumn(dataStore1.id, { - column: { name: 'myColumn1', type: 'string' }, + name: 'myColumn1', + type: 'string', }); // ACT @@ -200,7 +198,8 @@ describe('dataStore', () => { it('should fail when deleting unknown column', async () => { // ARRANGE await dataStoreService.addColumn(dataStore1.id, { - column: { name: 'myColumn1', type: 'string' }, + name: 'myColumn1', + type: 'string', }); // ACT @@ -214,7 +213,8 @@ describe('dataStore', () => { it('should fail when deleting column from unknown table', async () => { // ARRANGE await dataStoreService.addColumn(dataStore1.id, { - column: { name: 'myColumn1', type: 'string' }, + name: 'myColumn1', + type: 'string', }); // ACT @@ -436,10 +436,10 @@ describe('dataStore', () => { describe('appendRows', () => { it('appends a row to an existing table', async () => { // ARRANGE - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c1', type: 'number' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c2', type: 'boolean' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c3', type: 'date' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c4', type: 'string' } }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c1', type: 'number' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c2', type: 'boolean' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c3', type: 'date' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c4', type: 'string' }); // ACT const result = await dataStoreService.appendRows(dataStore1.id, [ @@ -454,10 +454,10 @@ describe('dataStore', () => { it('rejects a mismatched row with extra column', async () => { // ARRANGE - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c1', type: 'number' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c2', type: 'boolean' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c3', type: 'date' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c4', type: 'string' } }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c1', type: 'number' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c2', type: 'boolean' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c3', type: 'date' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c4', type: 'string' }); // ACT const result = await dataStoreService.appendRows(dataStore1.id, [ @@ -470,10 +470,10 @@ describe('dataStore', () => { }); it('rejects a mismatched row with missing column', async () => { // ARRANGE - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c1', type: 'number' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c2', type: 'boolean' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c3', type: 'date' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c4', type: 'string' } }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c1', type: 'number' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c2', type: 'boolean' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c3', type: 'date' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c4', type: 'string' }); // ACT const result = await dataStoreService.appendRows(dataStore1.id, [ @@ -486,10 +486,10 @@ describe('dataStore', () => { }); it('rejects a mismatched row with replaced column', async () => { // ARRANGE - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c1', type: 'number' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c2', type: 'boolean' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c3', type: 'date' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c4', type: 'string' } }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c1', type: 'number' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c2', type: 'boolean' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c3', type: 'date' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c4', type: 'string' }); // ACT const result = await dataStoreService.appendRows(dataStore1.id, [ @@ -502,10 +502,10 @@ describe('dataStore', () => { }); it('rejects unknown data store id', async () => { // ARRANGE - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c1', type: 'number' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c2', type: 'boolean' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c3', type: 'date' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c4', type: 'string' } }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c1', type: 'number' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c2', type: 'boolean' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c3', type: 'date' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c4', type: 'string' }); // ACT const result = await dataStoreService.appendRows('this is not an id', [ @@ -530,10 +530,10 @@ describe('dataStore', () => { describe('getManyRowsAndCount', () => { it('retrieves rows correctly', async () => { // ARRANGE - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c1', type: 'number' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c2', type: 'boolean' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c3', type: 'date' } }); - await dataStoreService.addColumn(dataStore1.id, { column: { name: 'c4', type: 'string' } }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c1', type: 'number' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c2', type: 'boolean' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c3', type: 'date' }); + await dataStoreService.addColumn(dataStore1.id, { name: 'c4', type: 'string' }); await dataStoreService.appendRows(dataStore1.id, [ { c1: 3, c2: true, c3: new Date(0), c4: 'hello?' }, diff --git a/packages/cli/src/modules/data-store/data-store-column.entity.ts b/packages/cli/src/modules/data-store/data-store-column.entity.ts index fc9ea9113da..9310cb6aaaa 100644 --- a/packages/cli/src/modules/data-store/data-store-column.entity.ts +++ b/packages/cli/src/modules/data-store/data-store-column.entity.ts @@ -1,8 +1,8 @@ +import { DataStoreCreateColumnSchema } from '@n8n/api-types/src/schemas/data-store.schema'; import { WithStringId } from '@n8n/db'; import { Column, Entity, Index, JoinColumn, ManyToOne } from '@n8n/typeorm'; import { type DataStoreEntity } from './data-store.entity'; -import { DataStoreColumnType } from './data-store.types'; @Entity() @Index(['dataStoreId', 'name'], { unique: true }) @@ -11,10 +11,13 @@ export class DataStoreColumnEntity extends WithStringId { dataStoreId: string; @Column() - name: string; + name: DataStoreCreateColumnSchema['name']; @Column({ type: 'varchar' }) - type: DataStoreColumnType; + type: DataStoreCreateColumnSchema['type']; + + @Column({ type: 'int' }) + columnIndex: number; @ManyToOne('DataStoreEntity', 'columns') @JoinColumn({ name: 'dataStoreId' }) diff --git a/packages/cli/src/modules/data-store/data-store-column.repository.ts b/packages/cli/src/modules/data-store/data-store-column.repository.ts index 5b2459e67a3..345f75dd4c8 100644 --- a/packages/cli/src/modules/data-store/data-store-column.repository.ts +++ b/packages/cli/src/modules/data-store/data-store-column.repository.ts @@ -25,4 +25,17 @@ export class DataStoreColumnRepository extends Repository async deleteColumn(dataStoreId: DataStoreUserTableName, column: string) { await this.manager.query(deleteColumnQuery(dataStoreId, column)); } + + async shiftColumns(dataStoreId: string, lowestIndex: number, delta: -1 | 1) { + await this.createQueryBuilder() + .update() + .set({ + columnIndex: () => `columnIndex + ${delta}`, + }) + .where('dataStoreId = :dataStoreId AND columnIndex > :thresholdValue', { + dataStoreId, + thresholdValue: lowestIndex, + }) + .execute(); + } } diff --git a/packages/cli/src/modules/data-store/data-store.service.ts b/packages/cli/src/modules/data-store/data-store.service.ts index 73a0b888ca5..36a2a9dfa7a 100644 --- a/packages/cli/src/modules/data-store/data-store.service.ts +++ b/packages/cli/src/modules/data-store/data-store.service.ts @@ -145,13 +145,8 @@ export class DataStoreService { return 'tried to add column to non-existent table'; } - const column = this.dataStoreColumnRepository.create({ - ...dto.column, - dataStoreId, - }); - const existingColumnMatch = await this.dataStoreColumnRepository.findBy({ - name: column.name, + name: dto.name, dataStoreId, }); @@ -159,6 +154,18 @@ export class DataStoreService { return 'tried to add column with name already present in this data store'; } + if (dto.columnIndex === undefined) { + const columns = await this.dataStoreColumnRepository.getColumns(dataStoreId); + dto.columnIndex = columns.length; + } else { + await this.dataStoreColumnRepository.shiftColumns(dataStoreId, dto.columnIndex, 1); + } + + const column = this.dataStoreColumnRepository.create({ + ...dto, + dataStoreId, + }); + await this.dataStoreColumnRepository.insert(column); await this.dataStoreColumnRepository.addColumn(toTableName(dataStoreId), column); @@ -185,6 +192,11 @@ export class DataStoreService { await this.dataStoreColumnRepository.remove(existingColumnMatch); await this.dataStoreColumnRepository.deleteColumn(toTableName(dataStoreId), dto.columnName); + await this.dataStoreColumnRepository.shiftColumns( + dataStoreId, + existingColumnMatch[0].columnIndex, + -1, + ); // should we update the main table entry's `updatedAt` field here? return true;