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:
+8
@@ -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(),
|
||||
|
||||
+41
-1
@@ -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[]>(
|
||||
|
||||
+616
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
-1
@@ -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', () => {
|
||||
+6
@@ -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();
|
||||
}
|
||||
|
||||
+3
@@ -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', {
|
||||
|
||||
+3
@@ -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;
|
||||
}
|
||||
|
||||
+43
-1
@@ -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(
|
||||
|
||||
+86
-8
@@ -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[]>(
|
||||
|
||||
Reference in New Issue
Block a user