Set record position on workflow creation (#11308)

- Migrate record position factory to core-modules 
- set position on record creation
This commit is contained in:
Thomas Trompette
2025-04-01 11:50:43 +02:00
committed by GitHub
parent b2012229f4
commit 023d071103
20 changed files with 303 additions and 317 deletions

View File

@ -3,8 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import { ServerBlockNoteEditor } from '@blocknote/server-util'; import { ServerBlockNoteEditor } from '@blocknote/server-util';
import chalk from 'chalk'; import chalk from 'chalk';
import { Command } from 'nest-commander'; import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataType } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { import {
ActiveOrSuspendedWorkspacesMigrationCommandOptions, ActiveOrSuspendedWorkspacesMigrationCommandOptions,

View File

@ -1,81 +0,0 @@
import {
RecordPositionQueryFactory,
RecordPositionQueryType,
} from 'src/engine/api/graphql/workspace-query-builder/factories/record-position-query.factory';
describe('RecordPositionQueryFactory', () => {
const objectMetadataItem = {
isCustom: false,
nameSingular: 'company',
};
const dataSourceSchema = 'workspace_test';
const factory: RecordPositionQueryFactory = new RecordPositionQueryFactory();
it('should be defined', () => {
expect(factory).toBeDefined();
});
describe('create', () => {
it('should return query and params for FIND_BY_POSITION', async () => {
const positionValue = 1;
const queryType = RecordPositionQueryType.FIND_BY_POSITION;
const [query, params] = factory.create(
{ positionValue, recordPositionQueryType: queryType },
objectMetadataItem,
dataSourceSchema,
);
expect(query).toEqual(
`SELECT id, position FROM ${dataSourceSchema}."${objectMetadataItem.nameSingular}"
WHERE "position" = $1`,
);
expect(params).toEqual([positionValue]);
});
it('should return query and params for FIND_MIN_POSITION', async () => {
const queryType = RecordPositionQueryType.FIND_MIN_POSITION;
const [query, params] = factory.create(
{ recordPositionQueryType: queryType },
objectMetadataItem,
dataSourceSchema,
);
expect(query).toEqual(
`SELECT MIN(position) as position FROM ${dataSourceSchema}."${objectMetadataItem.nameSingular}"`,
);
expect(params).toEqual([]);
});
it('should return query and params for FIND_MAX_POSITION', async () => {
const queryType = RecordPositionQueryType.FIND_MAX_POSITION;
const [query, params] = factory.create(
{ recordPositionQueryType: queryType },
objectMetadataItem,
dataSourceSchema,
);
expect(query).toEqual(
`SELECT MAX(position) as position FROM ${dataSourceSchema}."${objectMetadataItem.nameSingular}"`,
);
expect(params).toEqual([]);
});
it('should return query and params for UPDATE_POSITION', async () => {
const positionValue = 1;
const recordId = '1';
const queryType = RecordPositionQueryType.UPDATE_POSITION;
const [query, params] = factory.create(
{ positionValue, recordId, recordPositionQueryType: queryType },
objectMetadataItem,
dataSourceSchema,
);
expect(query).toEqual(
`UPDATE ${dataSourceSchema}."${objectMetadataItem.nameSingular}"
SET "position" = $1
WHERE "id" = $2`,
);
expect(params).toEqual([positionValue, recordId]);
});
});
});

View File

@ -1,8 +1,5 @@
import { ForeignDataWrapperServerQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/foreign-data-wrapper-server-query.factory'; import { ForeignDataWrapperServerQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/foreign-data-wrapper-server-query.factory';
import { RecordPositionQueryFactory } from './record-position-query.factory';
export const workspaceQueryBuilderFactories = [ export const workspaceQueryBuilderFactories = [
RecordPositionQueryFactory,
ForeignDataWrapperServerQueryFactory, ForeignDataWrapperServerQueryFactory,
]; ];

View File

@ -1,121 +0,0 @@
import { Injectable } from '@nestjs/common';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
export enum RecordPositionQueryType {
FIND_MIN_POSITION = 'FIND_MIN_POSITION',
FIND_MAX_POSITION = 'FIND_MAX_POSITION',
FIND_BY_POSITION = 'FIND_BY_POSITION',
UPDATE_POSITION = 'UPDATE_POSITION',
}
type FindByPositionQueryArgs = {
positionValue: number | null;
recordPositionQueryType: RecordPositionQueryType.FIND_BY_POSITION;
};
type FindMinPositionQueryArgs = {
recordPositionQueryType: RecordPositionQueryType.FIND_MIN_POSITION;
};
type FindMaxPositionQueryArgs = {
recordPositionQueryType: RecordPositionQueryType.FIND_MAX_POSITION;
};
type UpdatePositionQueryArgs = {
recordId: string;
positionValue: number;
recordPositionQueryType: RecordPositionQueryType.UPDATE_POSITION;
};
type RecordPositionQuery = string;
type RecordPositionQueryParams = any[];
export type RecordPositionQueryArgs =
| FindByPositionQueryArgs
| FindMinPositionQueryArgs
| FindMaxPositionQueryArgs
| UpdatePositionQueryArgs;
@Injectable()
export class RecordPositionQueryFactory {
create(
recordPositionQueryArgs: RecordPositionQueryArgs,
objectMetadata: { isCustom: boolean; nameSingular: string },
dataSourceSchema: string,
): [RecordPositionQuery, RecordPositionQueryParams] {
const tableName = computeTableName(
objectMetadata.nameSingular,
objectMetadata.isCustom,
);
switch (recordPositionQueryArgs.recordPositionQueryType) {
case RecordPositionQueryType.FIND_BY_POSITION:
return this.buildFindByPositionQuery(
recordPositionQueryArgs satisfies FindByPositionQueryArgs,
tableName,
dataSourceSchema,
);
case RecordPositionQueryType.FIND_MIN_POSITION:
return this.buildFindMinPositionQuery(tableName, dataSourceSchema);
case RecordPositionQueryType.FIND_MAX_POSITION:
return this.buildFindMaxPositionQuery(tableName, dataSourceSchema);
case RecordPositionQueryType.UPDATE_POSITION:
return this.buildUpdatePositionQuery(
recordPositionQueryArgs satisfies UpdatePositionQueryArgs,
tableName,
dataSourceSchema,
);
default:
throw new Error('Invalid RecordPositionQueryType');
}
}
private buildFindByPositionQuery(
{ positionValue }: FindByPositionQueryArgs,
name: string,
dataSourceSchema: string,
): [RecordPositionQuery, RecordPositionQueryParams] {
const positionStringParam = positionValue ? '= $1' : 'IS NULL';
return [
`SELECT id, position FROM ${dataSourceSchema}."${name}"
WHERE "position" ${positionStringParam}`,
positionValue ? [positionValue] : [],
];
}
private buildFindMaxPositionQuery(
name: string,
dataSourceSchema: string,
): [RecordPositionQuery, RecordPositionQueryParams] {
return [
`SELECT MAX(position) as position FROM ${dataSourceSchema}."${name}"`,
[],
];
}
private buildFindMinPositionQuery(
name: string,
dataSourceSchema: string,
): [RecordPositionQuery, RecordPositionQueryParams] {
return [
`SELECT MIN(position) as position FROM ${dataSourceSchema}."${name}"`,
[],
];
}
private buildUpdatePositionQuery(
{ recordId, positionValue }: UpdatePositionQueryArgs,
name: string,
dataSourceSchema: string,
): [RecordPositionQuery, RecordPositionQueryParams] {
return [
`UPDATE ${dataSourceSchema}."${name}"
SET "position" = $1
WHERE "id" = $2`,
[positionValue, recordId],
];
}
}

View File

@ -1,6 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { RecordPositionQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/record-position-query.factory';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { workspaceQueryBuilderFactories } from './factories/factories'; import { workspaceQueryBuilderFactories } from './factories/factories';
@ -8,6 +7,6 @@ import { workspaceQueryBuilderFactories } from './factories/factories';
@Module({ @Module({
imports: [ObjectMetadataModule], imports: [ObjectMetadataModule],
providers: [...workspaceQueryBuilderFactories], providers: [...workspaceQueryBuilderFactories],
exports: [RecordPositionQueryFactory], exports: [],
}) })
export class WorkspaceQueryBuilderModule {} export class WorkspaceQueryBuilderModule {}

View File

@ -7,14 +7,14 @@ import { ResolverArgsType } from 'src/engine/api/graphql/workspace-resolver-buil
import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory'; import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory';
import { import {
RecordPositionFactory, RecordPositionService,
RecordPositionFactoryCreateArgs, RecordPositionServiceCreateArgs,
} from 'src/engine/api/graphql/workspace-query-runner/factories/record-position.factory'; } from 'src/engine/core-modules/record-position/services/record-position.service';
import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map'; import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
describe('QueryRunnerArgsFactory', () => { describe('QueryRunnerArgsFactory', () => {
const recordPositionFactory = { const recordPositionService = {
create: jest.fn().mockResolvedValue(2), buildRecordPosition: jest.fn().mockResolvedValue(2),
}; };
const workspaceId = 'workspaceId'; const workspaceId = 'workspaceId';
const options = { const options = {
@ -66,10 +66,8 @@ describe('QueryRunnerArgsFactory', () => {
providers: [ providers: [
QueryRunnerArgsFactory, QueryRunnerArgsFactory,
{ {
provide: RecordPositionFactory, provide: RecordPositionService,
useValue: { useValue: recordPositionService,
create: recordPositionFactory.create,
},
}, },
], ],
}).compile(); }).compile();
@ -107,14 +105,16 @@ describe('QueryRunnerArgsFactory', () => {
ResolverArgsType.CreateMany, ResolverArgsType.CreateMany,
); );
const expectedArgs: RecordPositionFactoryCreateArgs = { const expectedArgs: RecordPositionServiceCreateArgs = {
value: 'last', value: 'last',
objectMetadata: { isCustom: true, nameSingular: 'testNumber' }, objectMetadata: { isCustom: true, nameSingular: 'testNumber' },
workspaceId, workspaceId,
index: 0, index: 0,
}; };
expect(recordPositionFactory.create).toHaveBeenCalledWith(expectedArgs); expect(recordPositionService.buildRecordPosition).toHaveBeenCalledWith(
expectedArgs,
);
expect(result).toEqual({ expect(result).toEqual({
id: 'uuid', id: 'uuid',
data: [{ position: 2, testNumber: 1 }], data: [{ position: 2, testNumber: 1 }],
@ -133,14 +133,16 @@ describe('QueryRunnerArgsFactory', () => {
ResolverArgsType.CreateMany, ResolverArgsType.CreateMany,
); );
const expectedArgs: RecordPositionFactoryCreateArgs = { const expectedArgs: RecordPositionServiceCreateArgs = {
value: 'first', value: 'first',
objectMetadata: { isCustom: true, nameSingular: 'testNumber' }, objectMetadata: { isCustom: true, nameSingular: 'testNumber' },
workspaceId, workspaceId,
index: 0, index: 0,
}; };
expect(recordPositionFactory.create).toHaveBeenCalledWith(expectedArgs); expect(recordPositionService.buildRecordPosition).toHaveBeenCalledWith(
expectedArgs,
);
expect(result).toEqual({ expect(result).toEqual({
id: 'uuid', id: 'uuid',
data: [{ position: 2, testNumber: 1 }], data: [{ position: 2, testNumber: 1 }],

View File

@ -1,10 +1,8 @@
import { QueryRunnerArgsFactory } from './query-runner-args.factory'; import { QueryRunnerArgsFactory } from './query-runner-args.factory';
import { RecordPositionFactory } from './record-position.factory';
import { QueryResultGettersFactory } from './query-result-getters/query-result-getters.factory'; import { QueryResultGettersFactory } from './query-result-getters/query-result-getters.factory';
export const workspaceQueryRunnerFactories = [ export const workspaceQueryRunnerFactories = [
QueryRunnerArgsFactory, QueryRunnerArgsFactory,
RecordPositionFactory,
QueryResultGettersFactory, QueryResultGettersFactory,
]; ];

View File

@ -22,14 +22,13 @@ import {
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { lowercaseDomain } from 'src/engine/api/graphql/workspace-query-runner/utils/query-runner-links.util'; import { lowercaseDomain } from 'src/engine/api/graphql/workspace-query-runner/utils/query-runner-links.util';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { import {
RichTextV2Metadata, RichTextV2Metadata,
richTextV2ValueSchema, richTextV2ValueSchema,
} from 'src/engine/metadata-modules/field-metadata/composite-types/rich-text-v2.composite-type'; } from 'src/engine/metadata-modules/field-metadata/composite-types/rich-text-v2.composite-type';
import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map'; import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
import { RecordPositionFactory } from './record-position.factory';
type ArgPositionBackfillInput = { type ArgPositionBackfillInput = {
argIndex?: number; argIndex?: number;
shouldBackfillPosition: boolean; shouldBackfillPosition: boolean;
@ -37,7 +36,7 @@ type ArgPositionBackfillInput = {
@Injectable() @Injectable()
export class QueryRunnerArgsFactory { export class QueryRunnerArgsFactory {
constructor(private readonly recordPositionFactory: RecordPositionFactory) {} constructor(private readonly recordPositionService: RecordPositionService) {}
async create( async create(
args: ResolverArgs, args: ResolverArgs,
@ -190,16 +189,18 @@ export class QueryRunnerArgsFactory {
case FieldMetadataType.POSITION: { case FieldMetadataType.POSITION: {
isFieldPositionPresent = true; isFieldPositionPresent = true;
const newValue = await this.recordPositionFactory.create({ const newValue = await this.recordPositionService.buildRecordPosition(
value, {
workspaceId, value,
objectMetadata: { workspaceId,
isCustom: options.objectMetadataItemWithFieldMaps.isCustom, objectMetadata: {
nameSingular: isCustom: options.objectMetadataItemWithFieldMaps.isCustom,
options.objectMetadataItemWithFieldMaps.nameSingular, nameSingular:
options.objectMetadataItemWithFieldMaps.nameSingular,
},
index: argPositionBackfillInput.argIndex,
}, },
index: argPositionBackfillInput.argIndex, );
});
return [key, newValue]; return [key, newValue];
} }
@ -307,7 +308,7 @@ export class QueryRunnerArgsFactory {
...newArgEntries, ...newArgEntries,
[ [
'position', 'position',
await this.recordPositionFactory.create({ await this.recordPositionService.buildRecordPosition({
value: 'first', value: 'first',
workspaceId, workspaceId,
objectMetadata: { objectMetadata: {

View File

@ -3,27 +3,21 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataType } from 'twenty-shared/types';
import { RecordPositionQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/record-position-query.factory';
import { RecordPositionFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/record-position.factory';
import { RecordPositionBackfillService } from 'src/engine/api/graphql/workspace-query-runner/services/record-position-backfill-service'; import { RecordPositionBackfillService } from 'src/engine/api/graphql/workspace-query-runner/services/record-position-backfill-service';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
describe('RecordPositionBackfillService', () => { describe('RecordPositionBackfillService', () => {
let recordPositionQueryFactory; let recordPositionService;
let recordPositionFactory;
let objectMetadataRepository; let objectMetadataRepository;
let workspaceDataSourceService; let workspaceDataSourceService;
let service: RecordPositionBackfillService; let service: RecordPositionBackfillService;
beforeEach(async () => { beforeEach(async () => {
recordPositionQueryFactory = { recordPositionService = {
create: jest.fn().mockReturnValue(['query', []]), buildRecordPosition: jest.fn().mockResolvedValue([
};
recordPositionFactory = {
create: jest.fn().mockResolvedValue([
{ {
position: 1, position: 1,
}, },
@ -42,12 +36,8 @@ describe('RecordPositionBackfillService', () => {
providers: [ providers: [
RecordPositionBackfillService, RecordPositionBackfillService,
{ {
provide: RecordPositionQueryFactory, provide: RecordPositionService,
useValue: recordPositionQueryFactory, useValue: recordPositionService,
},
{
provide: RecordPositionFactory,
useValue: recordPositionFactory,
}, },
{ {
provide: WorkspaceDataSourceService, provide: WorkspaceDataSourceService,
@ -123,8 +113,7 @@ describe('RecordPositionBackfillService', () => {
]); ]);
await service.backfill('workspaceId', false); await service.backfill('workspaceId', false);
expect(workspaceDataSourceService.executeRawQuery).toHaveBeenCalledTimes(2); expect(workspaceDataSourceService.executeRawQuery).toHaveBeenCalledTimes(2);
expect(recordPositionFactory.create).toHaveBeenCalledTimes(1); expect(recordPositionService.buildRecordPosition).toHaveBeenCalledTimes(1);
expect(recordPositionQueryFactory.create).toHaveBeenCalledTimes(2);
}); });
it('when dryRun is true, should not update position', async () => { it('when dryRun is true, should not update position', async () => {
@ -148,7 +137,6 @@ describe('RecordPositionBackfillService', () => {
]); ]);
await service.backfill('workspaceId', true); await service.backfill('workspaceId', true);
expect(workspaceDataSourceService.executeRawQuery).toHaveBeenCalledTimes(1); expect(workspaceDataSourceService.executeRawQuery).toHaveBeenCalledTimes(1);
expect(recordPositionFactory.create).toHaveBeenCalledTimes(1); expect(recordPositionService.buildRecordPosition).toHaveBeenCalledTimes(1);
expect(recordPositionQueryFactory.create).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@ -1,9 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { RecordPositionQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/record-position-query.factory';
import { RecordPositionFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/record-position.factory';
import { RecordPositionBackfillService } from 'src/engine/api/graphql/workspace-query-runner/services/record-position-backfill-service'; import { RecordPositionBackfillService } from 'src/engine/api/graphql/workspace-query-runner/services/record-position-backfill-service';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
@ -12,11 +11,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
WorkspaceDataSourceModule, WorkspaceDataSourceModule,
TypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'), TypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
], ],
providers: [ providers: [RecordPositionService, RecordPositionBackfillService],
RecordPositionFactory,
RecordPositionQueryFactory,
RecordPositionBackfillService,
],
exports: [RecordPositionBackfillService], exports: [RecordPositionBackfillService],
}) })
export class RecordPositionBackfillModule {} export class RecordPositionBackfillModule {}

View File

@ -2,14 +2,12 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'class-validator'; import { isDefined } from 'class-validator';
import { Repository } from 'typeorm';
import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataType } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
RecordPositionQueryFactory, import { RecordPositionQueryType } from 'src/engine/core-modules/record-position/types/record-position-query.type';
RecordPositionQueryType, import { buildRecordPositionQuery } from 'src/engine/core-modules/record-position/utils/build-record-position-query.util';
} from 'src/engine/api/graphql/workspace-query-builder/factories/record-position-query.factory';
import { RecordPositionFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/record-position.factory';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
@ -19,9 +17,8 @@ export class RecordPositionBackfillService {
constructor( constructor(
@InjectRepository(ObjectMetadataEntity, 'metadata') @InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>, private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly recordPositionFactory: RecordPositionFactory,
private readonly recordPositionQueryFactory: RecordPositionQueryFactory,
private readonly workspaceDataSourceService: WorkspaceDataSourceService, private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly recordPositionService: RecordPositionService,
) {} ) {}
async backfill(workspaceId: string, dryRun: boolean) { async backfill(workspaceId: string, dryRun: boolean) {
@ -47,7 +44,7 @@ export class RecordPositionBackfillService {
for (const objectMetadata of objectMetadataCollection) { for (const objectMetadata of objectMetadataCollection) {
const [recordsWithoutPositionQuery, recordsWithoutPositionQueryParams] = const [recordsWithoutPositionQuery, recordsWithoutPositionQueryParams] =
this.recordPositionQueryFactory.create( buildRecordPositionQuery(
{ {
recordPositionQueryType: RecordPositionQueryType.FIND_BY_POSITION, recordPositionQueryType: RecordPositionQueryType.FIND_BY_POSITION,
positionValue: null, positionValue: null,
@ -73,7 +70,7 @@ export class RecordPositionBackfillService {
continue; continue;
} }
const position = await this.recordPositionFactory.create({ const position = await this.recordPositionService.buildRecordPosition({
objectMetadata: { objectMetadata: {
isCustom: objectMetadata.isCustom, isCustom: objectMetadata.isCustom,
nameSingular: objectMetadata.nameSingular, nameSingular: objectMetadata.nameSingular,
@ -106,7 +103,7 @@ export class RecordPositionBackfillService {
continue; continue;
} }
const [query, params] = this.recordPositionQueryFactory.create( const [query, params] = buildRecordPositionQuery(
{ {
recordPositionQueryType: RecordPositionQueryType.UPDATE_POSITION, recordPositionQueryType: RecordPositionQueryType.UPDATE_POSITION,
recordId: recordsWithoutPosition[recordIndex].id, recordId: recordsWithoutPosition[recordIndex].id,

View File

@ -10,6 +10,7 @@ import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileModule } from 'src/engine/core-modules/file/file.module'; import { FileModule } from 'src/engine/core-modules/file/file.module';
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module'; import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
@ -29,6 +30,7 @@ import { EntityEventsToDbListener } from './listeners/entity-events-to-db.listen
TelemetryModule, TelemetryModule,
FileModule, FileModule,
FeatureFlagModule, FeatureFlagModule,
RecordPositionModule,
], ],
providers: [ providers: [
...workspaceQueryRunnerFactories, ...workspaceQueryRunnerFactories,

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { RecordPositionService } from './services/record-position.service';
@Module({
imports: [WorkspaceDataSourceModule],
providers: [RecordPositionService],
exports: [RecordPositionService],
})
export class RecordPositionModule {}

View File

@ -1,17 +1,12 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { RecordPositionQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/record-position-query.factory'; import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { RecordPositionFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/record-position.factory';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
describe('RecordPositionFactory', () => { describe('RecordPositionService', () => {
const recordPositionQueryFactory = {
create: jest.fn().mockReturnValue(['query', []]),
};
let workspaceDataSourceService; let workspaceDataSourceService;
let factory: RecordPositionFactory; let service: RecordPositionService;
beforeEach(async () => { beforeEach(async () => {
workspaceDataSourceService = { workspaceDataSourceService = {
@ -20,11 +15,7 @@ describe('RecordPositionFactory', () => {
}; };
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
RecordPositionFactory, RecordPositionService,
{
provide: RecordPositionQueryFactory,
useValue: recordPositionQueryFactory,
},
{ {
provide: WorkspaceDataSourceService, provide: WorkspaceDataSourceService,
useValue: workspaceDataSourceService, useValue: workspaceDataSourceService,
@ -32,11 +23,11 @@ describe('RecordPositionFactory', () => {
], ],
}).compile(); }).compile();
factory = module.get<RecordPositionFactory>(RecordPositionFactory); service = module.get<RecordPositionService>(RecordPositionService);
}); });
it('should be defined', () => { it('should be defined', () => {
expect(factory).toBeDefined(); expect(service).toBeDefined();
}); });
describe('create', () => { describe('create', () => {
@ -46,7 +37,7 @@ describe('RecordPositionFactory', () => {
it('should return the value when value is a number', async () => { it('should return the value when value is a number', async () => {
const value = 1; const value = 1;
const result = await factory.create({ const result = await service.buildRecordPosition({
value, value,
objectMetadata, objectMetadata,
workspaceId, workspaceId,
@ -57,7 +48,7 @@ describe('RecordPositionFactory', () => {
it('should return the existing position -1 when value is first', async () => { it('should return the existing position -1 when value is first', async () => {
const value = 'first'; const value = 'first';
const result = await factory.create({ const result = await service.buildRecordPosition({
value, value,
objectMetadata, objectMetadata,
workspaceId, workspaceId,
@ -68,7 +59,7 @@ describe('RecordPositionFactory', () => {
it('should return the existing position + 1 when value is last', async () => { it('should return the existing position + 1 when value is last', async () => {
const value = 'last'; const value = 'last';
const result = await factory.create({ const result = await service.buildRecordPosition({
value, value,
objectMetadata, objectMetadata,
workspaceId, workspaceId,

View File

@ -4,30 +4,30 @@ import { isDefined } from 'class-validator';
import { import {
RecordPositionQueryArgs, RecordPositionQueryArgs,
RecordPositionQueryFactory,
RecordPositionQueryType, RecordPositionQueryType,
} from 'src/engine/api/graphql/workspace-query-builder/factories/record-position-query.factory'; } from 'src/engine/core-modules/record-position/types/record-position-query.type';
import { buildRecordPositionQuery } from 'src/engine/core-modules/record-position/utils/build-record-position-query.util';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
export type RecordPositionFactoryCreateArgs = { export type RecordPositionServiceCreateArgs = {
value: number | 'first' | 'last'; value: number | 'first' | 'last';
objectMetadata: { isCustom: boolean; nameSingular: string }; objectMetadata: { isCustom: boolean; nameSingular: string };
workspaceId: string; workspaceId: string;
index?: number; index?: number;
}; };
@Injectable() @Injectable()
export class RecordPositionFactory { export class RecordPositionService {
constructor( constructor(
private readonly workspaceDataSourceService: WorkspaceDataSourceService, private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly recordPositionQueryFactory: RecordPositionQueryFactory,
) {} ) {}
async create({ async buildRecordPosition({
objectMetadata, objectMetadata,
value, value,
workspaceId, workspaceId,
index = 0, index = 0,
}: RecordPositionFactoryCreateArgs): Promise<number> { }: RecordPositionServiceCreateArgs): Promise<number> {
const dataSourceSchema = const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId); this.workspaceDataSourceService.getSchemaName(workspaceId);
@ -36,41 +36,43 @@ export class RecordPositionFactory {
} }
if (value === 'first') { if (value === 'first') {
const recordWithMinPosition = await this.findRecordPosition( const recordWithMinPosition =
{ await this.createAndExecuteRecordPositionQuery(
recordPositionQueryType: RecordPositionQueryType.FIND_MIN_POSITION, {
}, recordPositionQueryType: RecordPositionQueryType.FIND_MIN_POSITION,
objectMetadata, },
dataSourceSchema, objectMetadata,
workspaceId, dataSourceSchema,
); workspaceId,
);
return isDefined(recordWithMinPosition?.position) return isDefined(recordWithMinPosition?.position)
? recordWithMinPosition.position - index - 1 ? recordWithMinPosition.position - index - 1
: 1; : 1;
} }
const recordWithMaxPosition = await this.findRecordPosition( const recordWithMaxPosition =
{ await this.createAndExecuteRecordPositionQuery(
recordPositionQueryType: RecordPositionQueryType.FIND_MAX_POSITION, {
}, recordPositionQueryType: RecordPositionQueryType.FIND_MAX_POSITION,
objectMetadata, },
dataSourceSchema, objectMetadata,
workspaceId, dataSourceSchema,
); workspaceId,
);
return isDefined(recordWithMaxPosition?.position) return isDefined(recordWithMaxPosition?.position)
? recordWithMaxPosition.position + index + 1 ? recordWithMaxPosition.position + index + 1
: 1; : 1;
} }
private async findRecordPosition( private async createAndExecuteRecordPositionQuery(
recordPositionQueryArgs: RecordPositionQueryArgs, recordPositionQueryArgs: RecordPositionQueryArgs,
objectMetadata: { isCustom: boolean; nameSingular: string }, objectMetadata: { isCustom: boolean; nameSingular: string },
dataSourceSchema: string, dataSourceSchema: string,
workspaceId: string, workspaceId: string,
) { ) {
const [query, params] = this.recordPositionQueryFactory.create( const [query, params] = buildRecordPositionQuery(
recordPositionQueryArgs, recordPositionQueryArgs,
objectMetadata, objectMetadata,
dataSourceSchema, dataSourceSchema,

View File

@ -0,0 +1,31 @@
export type FindByPositionQueryArgs = {
positionValue: number | null;
recordPositionQueryType: RecordPositionQueryType.FIND_BY_POSITION;
};
export type FindMinPositionQueryArgs = {
recordPositionQueryType: RecordPositionQueryType.FIND_MIN_POSITION;
};
export type FindMaxPositionQueryArgs = {
recordPositionQueryType: RecordPositionQueryType.FIND_MAX_POSITION;
};
export type UpdatePositionQueryArgs = {
recordId: string;
positionValue: number;
recordPositionQueryType: RecordPositionQueryType.UPDATE_POSITION;
};
export enum RecordPositionQueryType {
FIND_MIN_POSITION = 'FIND_MIN_POSITION',
FIND_MAX_POSITION = 'FIND_MAX_POSITION',
FIND_BY_POSITION = 'FIND_BY_POSITION',
UPDATE_POSITION = 'UPDATE_POSITION',
}
export type RecordPositionQueryArgs =
| FindByPositionQueryArgs
| FindMinPositionQueryArgs
| FindMaxPositionQueryArgs
| UpdatePositionQueryArgs;

View File

@ -0,0 +1,72 @@
import { RecordPositionQueryType } from 'src/engine/core-modules/record-position/types/record-position-query.type';
import { buildRecordPositionQuery } from 'src/engine/core-modules/record-position/utils/build-record-position-query.util';
describe('buildRecordPositionQuery', () => {
const objectMetadataItem = {
isCustom: false,
nameSingular: 'company',
};
const dataSourceSchema = 'workspace_test';
it('should return query and params for FIND_BY_POSITION', async () => {
const positionValue = 1;
const queryType = RecordPositionQueryType.FIND_BY_POSITION;
const [query, params] = buildRecordPositionQuery(
{ positionValue, recordPositionQueryType: queryType },
objectMetadataItem,
dataSourceSchema,
);
expect(query).toEqual(
`SELECT id, position FROM ${dataSourceSchema}."${objectMetadataItem.nameSingular}"
WHERE "position" = $1`,
);
expect(params).toEqual([positionValue]);
});
it('should return query and params for FIND_MIN_POSITION', async () => {
const queryType = RecordPositionQueryType.FIND_MIN_POSITION;
const [query, params] = buildRecordPositionQuery(
{ recordPositionQueryType: queryType },
objectMetadataItem,
dataSourceSchema,
);
expect(query).toEqual(
`SELECT MIN(position) as position FROM ${dataSourceSchema}."${objectMetadataItem.nameSingular}"`,
);
expect(params).toEqual([]);
});
it('should return query and params for FIND_MAX_POSITION', async () => {
const queryType = RecordPositionQueryType.FIND_MAX_POSITION;
const [query, params] = buildRecordPositionQuery(
{ recordPositionQueryType: queryType },
objectMetadataItem,
dataSourceSchema,
);
expect(query).toEqual(
`SELECT MAX(position) as position FROM ${dataSourceSchema}."${objectMetadataItem.nameSingular}"`,
);
expect(params).toEqual([]);
});
it('should return query and params for UPDATE_POSITION', async () => {
const positionValue = 1;
const recordId = '1';
const queryType = RecordPositionQueryType.UPDATE_POSITION;
const [query, params] = buildRecordPositionQuery(
{ positionValue, recordId, recordPositionQueryType: queryType },
objectMetadataItem,
dataSourceSchema,
);
expect(query).toEqual(
`UPDATE ${dataSourceSchema}."${objectMetadataItem.nameSingular}"
SET "position" = $1
WHERE "id" = $2`,
);
expect(params).toEqual([positionValue, recordId]);
});
});

View File

@ -0,0 +1,90 @@
import {
FindByPositionQueryArgs,
RecordPositionQueryArgs,
RecordPositionQueryType,
UpdatePositionQueryArgs,
} from 'src/engine/core-modules/record-position/types/record-position-query.type';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
type RecordPositionQuery = string;
type RecordPositionQueryParams = any[];
export const buildRecordPositionQuery = (
recordPositionQueryArgs: RecordPositionQueryArgs,
objectMetadata: { isCustom: boolean; nameSingular: string },
dataSourceSchema: string,
): [RecordPositionQuery, RecordPositionQueryParams] => {
const tableName = computeTableName(
objectMetadata.nameSingular,
objectMetadata.isCustom,
);
switch (recordPositionQueryArgs.recordPositionQueryType) {
case RecordPositionQueryType.FIND_BY_POSITION:
return buildFindByPositionQuery(
recordPositionQueryArgs satisfies FindByPositionQueryArgs,
tableName,
dataSourceSchema,
);
case RecordPositionQueryType.FIND_MIN_POSITION:
return buildFindMinPositionQuery(tableName, dataSourceSchema);
case RecordPositionQueryType.FIND_MAX_POSITION:
return buildFindMaxPositionQuery(tableName, dataSourceSchema);
case RecordPositionQueryType.UPDATE_POSITION:
return buildUpdatePositionQuery(
recordPositionQueryArgs satisfies UpdatePositionQueryArgs,
tableName,
dataSourceSchema,
);
default:
throw new Error('Invalid RecordPositionQueryType');
}
};
const buildFindByPositionQuery = (
{ positionValue }: FindByPositionQueryArgs,
name: string,
dataSourceSchema: string,
): [RecordPositionQuery, RecordPositionQueryParams] => {
const positionStringParam = positionValue ? '= $1' : 'IS NULL';
return [
`SELECT id, position FROM ${dataSourceSchema}."${name}"
WHERE "position" ${positionStringParam}`,
positionValue ? [positionValue] : [],
];
};
const buildFindMaxPositionQuery = (
name: string,
dataSourceSchema: string,
): [RecordPositionQuery, RecordPositionQueryParams] => {
return [
`SELECT MAX(position) as position FROM ${dataSourceSchema}."${name}"`,
[],
];
};
const buildFindMinPositionQuery = (
name: string,
dataSourceSchema: string,
): [RecordPositionQuery, RecordPositionQueryParams] => {
return [
`SELECT MIN(position) as position FROM ${dataSourceSchema}."${name}"`,
[],
];
};
const buildUpdatePositionQuery = (
{ recordId, positionValue }: UpdatePositionQueryArgs,
name: string,
dataSourceSchema: string,
): [RecordPositionQuery, RecordPositionQueryParams] => {
return [
`UPDATE ${dataSourceSchema}."${name}"
SET "position" = $1
WHERE "id" = $2`,
[positionValue, recordId],
];
};

View File

@ -6,6 +6,7 @@ import { Repository } from 'typeorm';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface'; import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
@ -33,6 +34,7 @@ export class CreateRecordWorkflowAction implements WorkflowExecutor {
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>, private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly workspaceEventEmitter: WorkspaceEventEmitter, private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
private readonly recordPositionService: RecordPositionService,
) {} ) {}
async execute({ async execute({
@ -80,8 +82,15 @@ export class CreateRecordWorkflowAction implements WorkflowExecutor {
); );
} }
const position = await this.recordPositionService.buildRecordPosition({
value: 'first',
objectMetadata,
workspaceId,
});
const objectRecord = await repository.save({ const objectRecord = await repository.save({
...workflowActionInput.objectRecord, ...workflowActionInput.objectRecord,
position,
createdBy: { createdBy: {
source: FieldActorSource.WORKFLOW, source: FieldActorSource.WORKFLOW,
name: 'Workflow', name: 'Workflow',

View File

@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@ -16,6 +17,7 @@ import { UpdateRecordWorkflowAction } from 'src/modules/workflow/workflow-execut
WorkspaceCacheStorageModule, WorkspaceCacheStorageModule,
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'), NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
FeatureFlagModule, FeatureFlagModule,
RecordPositionModule,
], ],
providers: [ providers: [
ScopedWorkspaceContextFactory, ScopedWorkspaceContextFactory,