Files v2 - FILES field update/create in common API (#17400)

## FILES Field update interface

```
mutation {
  updateCompany(
    id: "company-uuid"
    data: {
      filesfield: [
          { fileId: "new-file-uuid", label: "new-document.pdf" }
        ]
    }
  ) {
    id
    filesfield {
      fileId
      label
      extension
    }
  }
}
```
This commit is contained in:
Etienne
2026-01-29 14:33:26 +01:00
committed by GitHub
parent 3dfb5ffc02
commit 09de762285
43 changed files with 1929 additions and 236 deletions
@@ -234,6 +234,12 @@ describe('WorkspaceEntityManager', () => {
emitDatabaseBatchEvent: jest.fn(),
emitCustomBatchEvent: jest.fn(),
} as any,
coreDataSource: {
getRepository: jest.fn(() => ({
find: jest.fn(),
softDelete: jest.fn(),
})),
} as any,
} as WorkspaceInternalContext;
mockDataSource = {
@@ -250,6 +256,7 @@ describe('WorkspaceEntityManager', () => {
},
permissionsPerRoleId: {},
eventEmitterService: mockInternalContext.eventEmitterService,
coreDataSource: mockInternalContext.coreDataSource,
} as GlobalWorkspaceDataSource;
mockPermissionOptions = {
@@ -305,6 +312,7 @@ describe('WorkspaceEntityManager', () => {
findColumnWithPropertyPath: jest.fn(),
}),
eventEmitterService: mockInternalContext.eventEmitterService,
coreDataSource: mockInternalContext.coreDataSource,
createQueryBuilder: jest.fn().mockReturnValue({
delete: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
@@ -50,8 +50,9 @@ import { type DeepPartialWithNestedRelationFields } from 'src/engine/twenty-orm/
import { type QueryDeepPartialEntityWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-nested-relation-fields.type';
import { getEntityTarget } from 'src/engine/twenty-orm/entity-manager/utils/get-entity-target';
import { computeTwentyORMException } from 'src/engine/twenty-orm/error-handling/compute-twenty-orm-exception';
import { FilesFieldSync } from 'src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync';
import { RelationNestedQueries } from 'src/engine/twenty-orm/field-operations/relation-nested-queries/relation-nested-queries';
import { type GlobalWorkspaceDataSource } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource';
import { RelationNestedQueries } from 'src/engine/twenty-orm/relation-nested-queries/relation-nested-queries';
import {
type OperationType,
validateOperationIsPermittedOrThrow,
@@ -111,6 +112,7 @@ export class WorkspaceEntityManager extends EntityManager {
featureFlagsMap: context.featureFlagsMap,
userWorkspaceRoleMap: context.userWorkspaceRoleMap,
eventEmitterService: this.eventEmitterService,
coreDataSource: this.connection.coreDataSource,
};
}
@@ -1216,6 +1218,37 @@ export class WorkspaceEntityManager extends EntityManager {
{} as Record<string, ObjectLiteral>,
);
const filesFieldSync = new FilesFieldSync(this.internalContext);
let filesFieldDiffByEntityIndex = null;
let filesFieldFileIds = null;
let fileIdToApplicationId = new Map<string, string>();
filesFieldDiffByEntityIndex =
filesFieldSync.computeFilesFieldDiffBeforeUpsert(
entityWithConnectedRelations,
entityTarget,
beforeUpdateMapById,
);
if (isDefined(filesFieldDiffByEntityIndex)) {
const result = await filesFieldSync.enrichFilesFields({
entities: entityWithConnectedRelations,
filesFieldDiffByEntityIndex,
workspaceId: this.internalContext.workspaceId,
target: entityTarget,
});
filesFieldFileIds = result.fileIds;
fileIdToApplicationId = result.fileIdToApplicationId;
entityWithConnectedRelations.splice(
0,
entityWithConnectedRelations.length,
...result.entities,
);
}
const objectMetadataItem = getObjectMetadataFromEntityTarget(
entityTarget,
this.internalContext,
@@ -1251,6 +1284,13 @@ export class WorkspaceEntityManager extends EntityManager {
.then(() => formattedEntityOrEntities as Entity[])
.finally(() => queryRunnerForEntityPersistExecutor.release());
if (isDefined(filesFieldFileIds)) {
await filesFieldSync.updateFileEntityRecords(
filesFieldFileIds,
fileIdToApplicationId,
);
}
const resultArray = Array.isArray(result) ? result : [result];
let formattedResult = formatResult<Entity[]>(
@@ -0,0 +1,616 @@
import path from 'path';
import { msg } from '@lingui/core/macro';
import {
FieldMetadataType,
type FieldMetadataSettingsMapping,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
In,
type EntityTarget,
type ObjectLiteral,
type Repository,
} from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { type WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util';
type FileItem = {
fileId: string;
label: string;
extension?: string;
};
type FilesFieldDiff = {
toAdd: FileItem[];
toUpdate: FileItem[];
toRemove: FileItem[];
};
type FilesFieldDiffByEntityIndex = {
[entityIndex: number]: {
[fieldName: string]: FilesFieldDiff;
};
};
export class FilesFieldSync {
private readonly internalContext: WorkspaceInternalContext;
private readonly fileRepository: Repository<FileEntity>;
constructor(internalContext: WorkspaceInternalContext) {
this.internalContext = internalContext;
this.fileRepository =
internalContext.coreDataSource.getRepository(FileEntity);
}
prepareFilesFieldSyncBeforeUpdate<Entity extends ObjectLiteral>(
entities: QueryDeepPartialEntity<Entity>[],
target: EntityTarget<Entity>,
existingRecords: ObjectLiteral[],
): FilesFieldDiffByEntityIndex | null {
return this.computeFilesFieldDiffBeforeUpdate(
entities,
target,
existingRecords,
);
}
computeFilesFieldDiffBeforeUpdate<Entity extends ObjectLiteral>(
entities: QueryDeepPartialEntity<Entity>[],
target: EntityTarget<Entity>,
existingRecords: ObjectLiteral[],
): FilesFieldDiffByEntityIndex | null {
const objectMetadata = getObjectMetadataFromEntityTarget(
target,
this.internalContext,
);
const filesFields = this.getFilesFields(objectMetadata.id);
if (filesFields.length === 0) {
return null;
}
const filesFieldDiffByEntityIndex: FilesFieldDiffByEntityIndex = {};
entities.forEach((entity, index) => {
const entityWithId = entity as { id?: string };
const existingRecord = existingRecords?.find(
(existing) =>
isDefined(existing.id) &&
isDefined(entityWithId.id) &&
existing.id === entityWithId.id,
);
for (const filesField of filesFields) {
const existingFilesValue = (existingRecord?.[filesField.name] ??
[]) as FileItem[];
const diff = this.validateAndComputeFilesFieldDiff(
entity as Record<string, unknown>,
filesField,
existingFilesValue,
);
if (diff) {
if (!filesFieldDiffByEntityIndex[index]) {
filesFieldDiffByEntityIndex[index] = {};
}
filesFieldDiffByEntityIndex[index][filesField.name] = diff;
}
}
});
return Object.keys(filesFieldDiffByEntityIndex).length > 0
? filesFieldDiffByEntityIndex
: null;
}
computeFilesFieldDiffBeforeUpdateOne<Entity extends ObjectLiteral>(
updatePayload: QueryDeepPartialEntity<Entity>,
target: EntityTarget<Entity>,
existingRecords: ObjectLiteral[],
): FilesFieldDiffByEntityIndex | null {
const objectMetadata = getObjectMetadataFromEntityTarget(
target,
this.internalContext,
);
const filesFields = this.getFilesFields(objectMetadata.id);
if (filesFields.length === 0) {
return null;
}
if (existingRecords.length !== 1) {
throw new TwentyORMException(
`Cannot update multiple records with files field at once`,
TwentyORMExceptionCode.INVALID_INPUT,
{
userFriendlyMessage: msg`You can only update one record with files field at once.`,
},
);
}
const filesFieldDiffByEntityIndex: FilesFieldDiffByEntityIndex = {};
const existingRecord = existingRecords[0];
for (const filesField of filesFields) {
const existingFilesValue = (existingRecord?.[filesField.name] ??
[]) as FileItem[];
const diff = this.validateAndComputeFilesFieldDiff(
updatePayload as Record<string, unknown>,
filesField,
existingFilesValue,
);
if (diff) {
if (!filesFieldDiffByEntityIndex[0]) {
filesFieldDiffByEntityIndex[0] = {};
}
filesFieldDiffByEntityIndex[0][filesField.name] = diff;
}
}
return Object.keys(filesFieldDiffByEntityIndex).length > 0
? filesFieldDiffByEntityIndex
: null;
}
computeFilesFieldDiffBeforeInsert<Entity extends ObjectLiteral>(
entities: QueryDeepPartialEntity<Entity>[],
target: EntityTarget<Entity>,
): FilesFieldDiffByEntityIndex | null {
const objectMetadata = getObjectMetadataFromEntityTarget(
target,
this.internalContext,
);
const filesFields = this.getFilesFields(objectMetadata.id);
if (filesFields.length === 0) {
return null;
}
const filesFieldDiffByEntityIndex: FilesFieldDiffByEntityIndex = {};
entities.forEach((entity, index) => {
for (const filesField of filesFields) {
const diff = this.validateAndComputeFilesFieldDiff(
entity as Record<string, unknown>,
filesField,
[],
);
if (diff) {
if (!filesFieldDiffByEntityIndex[index]) {
filesFieldDiffByEntityIndex[index] = {};
}
filesFieldDiffByEntityIndex[index][filesField.name] = diff;
}
}
});
return Object.keys(filesFieldDiffByEntityIndex).length > 0
? filesFieldDiffByEntityIndex
: null;
}
computeFilesFieldDiffBeforeUpsert<Entity extends ObjectLiteral>(
entities: QueryDeepPartialEntity<Entity>[],
target: EntityTarget<Entity>,
existingRecordsMapById: Record<string, ObjectLiteral>,
): FilesFieldDiffByEntityIndex | null {
const objectMetadata = getObjectMetadataFromEntityTarget(
target,
this.internalContext,
);
const filesFields = this.getFilesFields(objectMetadata.id);
if (filesFields.length === 0) {
return null;
}
const filesFieldDiffByEntityIndex: FilesFieldDiffByEntityIndex = {};
entities.forEach((entity, index) => {
const entityWithId = entity as { id?: string };
const existingRecord = isDefined(entityWithId.id)
? existingRecordsMapById[entityWithId.id]
: undefined;
for (const filesField of filesFields) {
const existingFilesValue = existingRecord
? ((existingRecord[filesField.name] ?? []) as FileItem[])
: [];
const diff = this.validateAndComputeFilesFieldDiff(
entity as Record<string, unknown>,
filesField,
existingFilesValue,
);
if (diff) {
if (!filesFieldDiffByEntityIndex[index]) {
filesFieldDiffByEntityIndex[index] = {};
}
filesFieldDiffByEntityIndex[index][filesField.name] = diff;
}
}
});
return Object.keys(filesFieldDiffByEntityIndex).length > 0
? filesFieldDiffByEntityIndex
: null;
}
private validateFilesFieldMaxValues(
filesField: FlatFieldMetadata,
newFilesValue: FileItem[],
): void {
const filesFieldMaxNumberOfValues = (
filesField.settings as FieldMetadataSettingsMapping[FieldMetadataType.FILES]
).maxNumberOfValues;
if (newFilesValue.length > filesFieldMaxNumberOfValues) {
throw new TwentyORMException(
`Max number of files is ${filesFieldMaxNumberOfValues}`,
TwentyORMExceptionCode.INVALID_INPUT,
{
userFriendlyMessage: msg`Max number of files is ${filesFieldMaxNumberOfValues}`,
},
);
}
}
private validateAndComputeFilesFieldDiff(
entity: Record<string, unknown>,
filesField: FlatFieldMetadata,
existingFilesValue: FileItem[],
): FilesFieldDiff | null {
const newFilesValue = entity[filesField.name] as
| FileItem[]
| null
| undefined;
if (!isDefined(newFilesValue)) {
return null;
}
this.validateFilesFieldMaxValues(filesField, newFilesValue);
const diff = this.computeFilesFieldDiff(existingFilesValue, newFilesValue);
if (
diff.toAdd.length > 0 ||
diff.toUpdate.length > 0 ||
diff.toRemove.length > 0
) {
return diff;
}
return null;
}
private computeFilesFieldDiff(
existingFiles: FileItem[],
newFiles: FileItem[],
): FilesFieldDiff {
const existingFileMap = new Map(
existingFiles.map((file) => [file.fileId, file]),
);
const newFileMap = new Map(newFiles.map((file) => [file.fileId, file]));
const toAdd: FileItem[] = [];
const toUpdate: FileItem[] = [];
const toRemove: FileItem[] = [];
for (const newFile of newFiles) {
const existingFile = existingFileMap.get(newFile.fileId);
if (!isDefined(existingFile)) {
toAdd.push(newFile);
} else {
toUpdate.push({
...existingFile,
label: newFile.label,
});
}
}
for (const existingFile of existingFiles) {
if (!newFileMap.has(existingFile.fileId)) {
toRemove.push(existingFile);
}
}
return { toAdd, toUpdate, toRemove };
}
async enrichFilesFields<Entity extends ObjectLiteral>({
entities,
filesFieldDiffByEntityIndex,
workspaceId,
target,
}: {
entities: QueryDeepPartialEntity<Entity>[];
filesFieldDiffByEntityIndex: FilesFieldDiffByEntityIndex;
workspaceId: string;
target: EntityTarget<Entity>;
}): Promise<{
entities: QueryDeepPartialEntity<Entity>[];
fileIds: {
toAdd: Set<string>;
toUpdate: Set<string>;
toRemove: Set<string>;
};
fileIdToApplicationId: Map<string, string>;
}> {
if (Object.keys(filesFieldDiffByEntityIndex).length === 0) {
return {
entities,
fileIds: {
toAdd: new Set<string>(),
toUpdate: new Set<string>(),
toRemove: new Set<string>(),
},
fileIdToApplicationId: new Map(),
};
}
const objectMetadata = getObjectMetadataFromEntityTarget(
target,
this.internalContext,
);
const { toAdd, toUpdate, toRemove, fileIdToApplicationId } =
await this.validateAndEnrichFileDiffs(
filesFieldDiffByEntityIndex,
workspaceId,
objectMetadata.id,
);
const updatedEntities = this.updateEntitiesWithEnrichedFilesFieldValues(
entities,
filesFieldDiffByEntityIndex,
);
return {
entities: updatedEntities,
fileIds: { toAdd, toUpdate, toRemove },
fileIdToApplicationId,
};
}
private async validateAndEnrichFileDiffs(
filesFieldDiffByEntityIndex: FilesFieldDiffByEntityIndex,
workspaceId: string,
objectMetadataId: string,
): Promise<{
toAdd: Set<string>;
toUpdate: Set<string>;
toRemove: Set<string>;
fileIdToApplicationId: Map<string, string>;
}> {
const allFileIds = {
toAdd: new Set<string>(),
toUpdate: new Set<string>(),
toRemove: new Set<string>(),
};
const fileIdToApplicationId = new Map<string, string>();
const allFileIdsToFetch = new Set<string>();
const filesFields = this.getFilesFields(objectMetadataId);
const fieldNameToApplicationId = new Map(
filesFields.map((field) => [field.name, field.applicationId]),
);
for (const entityDiffs of Object.values(filesFieldDiffByEntityIndex)) {
for (const [fieldName, diff] of Object.entries(entityDiffs)) {
const fieldApplicationId = fieldNameToApplicationId.get(fieldName);
diff.toAdd.forEach((file) => {
allFileIds.toAdd.add(file.fileId);
allFileIdsToFetch.add(file.fileId);
if (fieldApplicationId) {
fileIdToApplicationId.set(file.fileId, fieldApplicationId);
}
});
diff.toUpdate.forEach((file) => {
allFileIds.toUpdate.add(file.fileId);
allFileIdsToFetch.add(file.fileId);
});
diff.toRemove.forEach((file) => {
allFileIds.toRemove.add(file.fileId);
});
}
}
if (allFileIdsToFetch.size === 0 && allFileIds.toRemove.size === 0) {
return { ...allFileIds, fileIdToApplicationId };
}
const existingFiles = await this.fileRepository.find({
where: {
id: In([...allFileIdsToFetch, ...allFileIds.toRemove]),
workspaceId,
},
select: ['id', 'path', 'settings'],
});
const existingFileMap = new Map(
existingFiles.map((file) => [file.id, file]),
);
for (const entityDiffs of Object.values(filesFieldDiffByEntityIndex)) {
for (const diff of Object.values(entityDiffs)) {
for (const file of diff.toAdd) {
const fileEntity = existingFileMap.get(file.fileId);
if (!fileEntity) {
throw new TwentyORMException(
`File not found: ${file.fileId}`,
TwentyORMExceptionCode.INVALID_INPUT,
);
}
if (!fileEntity.settings?.isTemporaryFile) {
const fileId = file.fileId;
throw new TwentyORMException(
`File ${fileId} is already associated with a permanent files field`,
TwentyORMExceptionCode.INVALID_INPUT,
{
userFriendlyMessage: msg`File ${fileId} is already associated with a permanent files field. Please re-upload the file.`,
},
);
}
file.extension = path.extname(fileEntity.path);
}
for (const file of diff.toUpdate) {
const fileEntity = existingFileMap.get(file.fileId);
if (!fileEntity) {
throw new TwentyORMException(
`File not found: ${file.fileId}`,
TwentyORMExceptionCode.INVALID_INPUT,
);
}
if (fileEntity.settings?.isTemporaryFile) {
throw new TwentyORMException(
`File ${file.fileId} to update should not be a temporary file`,
TwentyORMExceptionCode.INVALID_INPUT,
{
userFriendlyMessage: STANDARD_ERROR_MESSAGE,
},
);
}
}
}
}
return { ...allFileIds, fileIdToApplicationId };
}
async updateFileEntityRecords(
fileIds: {
toAdd: Set<string>;
toUpdate: Set<string>;
toRemove: Set<string>;
},
fileIdToApplicationId: Map<string, string>,
): Promise<void> {
if (fileIds.toAdd.size > 0) {
const fileIdsByApplicationId = Array.from(fileIds.toAdd).reduce(
(acc, fileId) => {
const applicationId = fileIdToApplicationId.get(fileId);
if (!applicationId) {
throw new TwentyORMException(
`Application ID not found for file ${fileId}`,
TwentyORMExceptionCode.INVALID_INPUT,
);
}
acc[applicationId] = [...(acc[applicationId] || []), fileId];
return acc;
},
{} as Record<string, string[]>,
);
for (const [applicationId, fileIds] of Object.entries(
fileIdsByApplicationId,
)) {
await this.fileRepository.update(
{ id: In(fileIds) },
{
settings: {
isTemporaryFile: false,
toDelete: false,
},
applicationId,
},
);
}
}
if (fileIds.toRemove.size > 0) {
await this.fileRepository.softDelete([...fileIds.toRemove]);
}
}
private updateEntitiesWithEnrichedFilesFieldValues<
Entity extends ObjectLiteral,
>(
entities: QueryDeepPartialEntity<Entity>[],
filesFieldDiffByEntityIndex: FilesFieldDiffByEntityIndex,
): QueryDeepPartialEntity<Entity>[] {
return entities.map((entity, index) => {
const entityDiffs = filesFieldDiffByEntityIndex[index];
if (!entityDiffs) {
return entity;
}
const updatedEntity = { ...entity };
const updatedEntityAny = updatedEntity as Record<string, unknown>;
for (const [fieldName, diff] of Object.entries(entityDiffs)) {
const entityAny = entity as Record<string, unknown>;
const currentFiles = (entityAny[fieldName] ?? []) as FileItem[];
const toAddMap = new Map(diff.toAdd.map((file) => [file.fileId, file]));
const toUpdateMap = new Map(
diff.toUpdate.map((file) => [file.fileId, file]),
);
const updatedFiles = currentFiles.map((file) => {
const enrichedFile =
toAddMap.get(file.fileId) || toUpdateMap.get(file.fileId);
return enrichedFile || file;
});
updatedEntityAny[fieldName] = updatedFiles;
}
return updatedEntity;
});
}
private getFilesFields(objectMetadataId: string): FlatFieldMetadata[] {
const objectMetadata =
this.internalContext.flatObjectMetadataMaps.byId[objectMetadataId];
if (!objectMetadata) {
return [];
}
const objectFields = getFlatFieldsFromFlatObjectMetadata(
objectMetadata,
this.internalContext.flatFieldMetadataMaps,
);
return objectFields.filter(
(field) => field.type === FieldMetadataType.FILES,
);
}
}
@@ -19,7 +19,7 @@ import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { formatConnectRecordNotFoundErrorMessage } from 'src/engine/twenty-orm/relation-nested-queries/utils/formatConnectRecordNotFoundErrorMessage.util';
import { formatConnectRecordNotFoundErrorMessage } from 'src/engine/twenty-orm/field-operations/relation-nested-queries/utils/formatConnectRecordNotFoundErrorMessage.util';
import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { computeRelationConnectQueryConfigs } from 'src/engine/twenty-orm/utils/compute-relation-connect-query-configs.util';
import { createSqlWhereTupleInClause } from 'src/engine/twenty-orm/utils/create-sql-where-tuple-in-clause.utils';
@@ -1,4 +1,4 @@
import { formatConnectRecordNotFoundErrorMessage } from 'src/engine/twenty-orm/relation-nested-queries/utils/formatConnectRecordNotFoundErrorMessage.util';
import { formatConnectRecordNotFoundErrorMessage } from 'src/engine/twenty-orm/field-operations/relation-nested-queries/utils/formatConnectRecordNotFoundErrorMessage.util';
describe('formatConnectRecordNotFoundErrorMessage', () => {
it('should format the error message correctly', () => {
@@ -3,8 +3,10 @@ import {
OnApplicationShutdown,
OnModuleInit,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { DataSource } from 'typeorm';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { GlobalWorkspaceDataSource } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource';
@@ -21,6 +23,8 @@ export class GlobalWorkspaceDataSourceService
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {}
async onModuleInit(): Promise<void> {
@@ -49,6 +53,7 @@ export class GlobalWorkspaceDataSourceService
},
},
this.workspaceEventEmitter,
this.coreDataSource,
);
await this.globalWorkspaceDataSource.initialize();
@@ -83,6 +88,7 @@ export class GlobalWorkspaceDataSourceService
},
},
this.workspaceEventEmitter,
this.coreDataSource,
);
await this.globalWorkspaceDataSourceReplica.initialize();
}
@@ -33,15 +33,18 @@ type CreateQueryBuilderOptions = {
export class GlobalWorkspaceDataSource extends DataSource {
readonly eventEmitterService: WorkspaceEventEmitter;
readonly coreDataSource: DataSource;
private _isConstructing = true;
dataSourceWithOverridenCreateQueryBuilder: GlobalWorkspaceDataSource;
constructor(
options: DataSourceOptions,
eventEmitterService: WorkspaceEventEmitter,
coreDataSource: DataSource,
) {
super(options);
this.eventEmitterService = eventEmitterService;
this.coreDataSource = coreDataSource;
this._isConstructing = false;
Object.defineProperty(this, 'manager', {
@@ -1,3 +1,5 @@
import { type DataSource } from 'typeorm';
import { type FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -18,4 +20,5 @@ export interface WorkspaceInternalContext {
featureFlagsMap: Record<FeatureFlagKey, boolean>;
userWorkspaceRoleMap: Record<string, string>;
eventEmitterService: WorkspaceEventEmitter;
coreDataSource: DataSource;
}
@@ -22,7 +22,8 @@ import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { RelationNestedQueries } from 'src/engine/twenty-orm/relation-nested-queries/relation-nested-queries';
import { FilesFieldSync } from 'src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync';
import { RelationNestedQueries } from 'src/engine/twenty-orm/field-operations/relation-nested-queries/relation-nested-queries';
import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils';
import { type WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-delete-query-builder';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
@@ -46,6 +47,7 @@ export class WorkspaceInsertQueryBuilder<
private relationNestedConfig:
| [RelationConnectQueryConfig[], RelationDisconnectQueryFieldsByEntityIndex]
| null;
private filesFieldSync: FilesFieldSync;
constructor(
queryBuilder: InsertQueryBuilder<T>,
@@ -64,6 +66,7 @@ export class WorkspaceInsertQueryBuilder<
this.relationNestedQueries = new RelationNestedQueries(
this.internalContext,
);
this.filesFieldSync = new FilesFieldSync(this.internalContext);
}
override clone(): this {
@@ -158,6 +161,38 @@ export class WorkspaceInsertQueryBuilder<
this.internalContext,
);
let filesFieldFileIds = null;
let fileIdToApplicationId = new Map<string, string>();
const entities = Array.isArray(this.expressionMap.valuesSet)
? this.expressionMap.valuesSet
: [this.expressionMap.valuesSet];
const filesFieldDiffByEntityIndex =
this.filesFieldSync.computeFilesFieldDiffBeforeInsert(
entities as QueryDeepPartialEntityWithNestedRelationFields<T>[],
mainAliasTarget,
);
if (isDefined(filesFieldDiffByEntityIndex)) {
const result = await this.filesFieldSync.enrichFilesFields({
entities:
entities as QueryDeepPartialEntityWithNestedRelationFields<T>[],
filesFieldDiffByEntityIndex,
workspaceId: this.internalContext.workspaceId,
target: mainAliasTarget,
});
filesFieldFileIds = result.fileIds;
fileIdToApplicationId = result.fileIdToApplicationId;
this.expressionMap.valuesSet = Array.isArray(
this.expressionMap.valuesSet,
)
? result.entities
: result.entities[0];
}
if (isDefined(this.relationNestedConfig)) {
const nestedRelationQueryBuilder = new WorkspaceSelectQueryBuilder(
this as unknown as WorkspaceSelectQueryBuilder<T>,
@@ -183,6 +218,13 @@ export class WorkspaceInsertQueryBuilder<
this.validateRLSPredicatesForInsert();
const result = await super.execute();
if (isDefined(filesFieldFileIds)) {
await this.filesFieldSync.updateFileEntityRecords(
filesFieldFileIds,
fileIdToApplicationId,
);
}
const eventSelectQueryBuilder = (
this.connection.manager as WorkspaceEntityManager
).createQueryBuilder(
@@ -26,7 +26,8 @@ import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { RelationNestedQueries } from 'src/engine/twenty-orm/relation-nested-queries/relation-nested-queries';
import { FilesFieldSync } from 'src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync';
import { RelationNestedQueries } from 'src/engine/twenty-orm/field-operations/relation-nested-queries/relation-nested-queries';
import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils';
import { type WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-delete-query-builder';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
@@ -57,6 +58,7 @@ export class WorkspaceUpdateQueryBuilder<
criteria: string;
partialEntity: QueryDeepPartialEntity<T>;
}[];
private filesFieldSync: FilesFieldSync;
constructor(
queryBuilder: UpdateQueryBuilder<T>,
@@ -75,6 +77,7 @@ export class WorkspaceUpdateQueryBuilder<
this.relationNestedQueries = new RelationNestedQueries(
this.internalContext,
);
this.filesFieldSync = new FilesFieldSync(this.internalContext);
}
override clone(): this {
@@ -154,6 +157,44 @@ export class WorkspaceUpdateQueryBuilder<
this.featureFlagMap,
);
const formattedBefore = formatResult<T[]>(
before,
objectMetadata,
this.internalContext.flatObjectMetadataMaps,
this.internalContext.flatFieldMetadataMaps,
);
let filesFieldDiffByEntityIndex = null;
let filesFieldFileIds = null;
let fileIdToApplicationId = new Map<string, string>();
const updatePayload = Array.isArray(this.expressionMap.valuesSet)
? (this.expressionMap.valuesSet[0] ?? {})
: (this.expressionMap.valuesSet ?? {});
filesFieldDiffByEntityIndex =
this.filesFieldSync.computeFilesFieldDiffBeforeUpdateOne(
updatePayload,
mainAliasTarget,
formattedBefore,
);
if (isDefined(filesFieldDiffByEntityIndex)) {
const entities = formattedBefore.map(() => updatePayload);
const result = await this.filesFieldSync.enrichFilesFields({
entities,
filesFieldDiffByEntityIndex,
workspaceId: this.internalContext.workspaceId,
target: mainAliasTarget,
});
filesFieldFileIds = result.fileIds;
fileIdToApplicationId = result.fileIdToApplicationId;
this.expressionMap.valuesSet = result.entities[0];
}
if (isDefined(this.relationNestedConfig)) {
const updatedValues =
await this.relationNestedQueries.processRelationNestedQueries({
@@ -168,13 +209,6 @@ export class WorkspaceUpdateQueryBuilder<
updatedValues.length === 1 ? updatedValues[0] : updatedValues;
}
const formattedBefore = formatResult<T[]>(
before,
objectMetadata,
this.internalContext.flatObjectMetadataMaps,
this.internalContext.flatFieldMetadataMaps,
);
this.applyRowLevelPermissionPredicates();
const valuesSet = this.expressionMap.valuesSet ?? {};
@@ -194,6 +228,13 @@ export class WorkspaceUpdateQueryBuilder<
const result = await super.execute();
if (isDefined(filesFieldFileIds)) {
await this.filesFieldSync.updateFileEntityRecords(
filesFieldFileIds,
fileIdToApplicationId,
);
}
const after = await eventSelectQueryBuilder.getMany();
const formattedAfter = formatResult<T[]>(
@@ -324,6 +365,36 @@ export class WorkspaceUpdateQueryBuilder<
mainAliasTarget,
);
let filesFieldDiffByEntityIndex = null;
let filesFieldFileIds = null;
let fileIdToApplicationId = null;
const entities = this.manyInputs.map((input) => input.partialEntity);
filesFieldDiffByEntityIndex =
this.filesFieldSync.computeFilesFieldDiffBeforeUpdate(
entities,
mainAliasTarget,
formattedBefore,
);
if (isDefined(filesFieldDiffByEntityIndex)) {
const result = await this.filesFieldSync.enrichFilesFields({
entities,
filesFieldDiffByEntityIndex,
workspaceId: this.internalContext.workspaceId,
target: mainAliasTarget,
});
filesFieldFileIds = result.fileIds;
fileIdToApplicationId = result.fileIdToApplicationId;
this.manyInputs = result.entities.map((updatedEntity, index) => ({
criteria: this.manyInputs[index].criteria,
partialEntity: updatedEntity,
}));
}
if (isDefined(this.relationNestedConfig)) {
const updatedValues =
await this.relationNestedQueries.processRelationNestedQueries({
@@ -373,6 +444,13 @@ export class WorkspaceUpdateQueryBuilder<
results.push(result);
}
if (isDefined(filesFieldFileIds) && isDefined(fileIdToApplicationId)) {
await this.filesFieldSync.updateFileEntityRecords(
filesFieldFileIds,
fileIdToApplicationId,
);
}
const afterRecords = await eventSelectQueryBuilder.getMany();
const formattedAfter = formatResult<T[]>(