Deprecate object metadata maps in favor of flat entities (#16080)

## Context
Deprecating the old objectMetadataMap type in favour of split flat
entities to match with our new caching.
In the long run, trying to achieve:
- Better performance through caching
- Consistent data access patterns across the codebase
- Reduced database queries

Now that everything is based on flat entities, which are cached, we can
finish the refactoring of workspace context cache which should already
improve performances.
Then the last step will be to consume that new cache in the new global
datasource to get rid of the many workspace datasources stored in the
server
This commit is contained in:
Weiko
2025-11-27 13:43:34 +01:00
committed by GitHub
parent 978c0acb90
commit 1607aebcc6
281 changed files with 6510 additions and 6627 deletions
@@ -0,0 +1,168 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
const workspaceId = '20202020-0000-0000-0000-000000000000';
const personNameFieldId = 'person-name-field-id';
const companyNameFieldId = 'company-name-field-id';
const companyDomainNameFieldId = 'company-domain-name-field-id';
const customObjectNameFieldId = 'custom-object-name-field-id';
const customObjectImageFieldId = 'custom-object-image-field-id';
export const mockFlatObjectMetadatas: FlatObjectMetadata[] = [
getFlatObjectMetadataMock({
id: '20202020-8dec-43d5-b2ff-6eef05095bec',
nameSingular: 'person',
namePlural: 'people',
labelSingular: 'Person',
labelPlural: 'People',
description: 'A person',
icon: 'test-person-icon',
isCustom: false,
isSearchable: true,
labelIdentifierFieldMetadataId: personNameFieldId,
imageIdentifierFieldMetadataId: null,
workspaceId,
fieldMetadataIds: [personNameFieldId],
universalIdentifier: 'person-universal-id',
applicationId: workspaceId,
}),
getFlatObjectMetadataMock({
id: '20202020-c03c-45d6-a4b0-04afe1357c5c',
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
description: 'A company',
icon: 'test-company-icon',
isCustom: false,
isSearchable: true,
labelIdentifierFieldMetadataId: companyNameFieldId,
imageIdentifierFieldMetadataId: null,
workspaceId,
fieldMetadataIds: [companyNameFieldId, companyDomainNameFieldId],
universalIdentifier: 'company-universal-id',
applicationId: workspaceId,
}),
getFlatObjectMetadataMock({
id: '20202020-3d75-4aab-bacd-ee176c5f63ca',
nameSingular: 'regular-custom-object',
namePlural: 'regular-custom-objects',
labelSingular: 'Regular Custom Object',
labelPlural: 'Regular Custom Objects',
description: 'A regular custom object',
icon: 'test-regular-custom-object-icon',
isCustom: true,
isSearchable: true,
labelIdentifierFieldMetadataId: customObjectNameFieldId,
imageIdentifierFieldMetadataId: customObjectImageFieldId,
workspaceId,
fieldMetadataIds: [customObjectNameFieldId, customObjectImageFieldId],
universalIdentifier: 'custom-object-universal-id',
applicationId: workspaceId,
}),
getFlatObjectMetadataMock({
id: '20202020-540c-4397-b872-2a90ea2fb809',
nameSingular: 'non-searchable-object',
namePlural: 'non-searchable-objects',
labelSingular: '',
labelPlural: '',
description: '',
icon: 'test-non-searchable-object-icon',
isCustom: false,
isSystem: true,
isSearchable: false,
labelIdentifierFieldMetadataId: null,
imageIdentifierFieldMetadataId: null,
workspaceId,
fieldMetadataIds: [],
universalIdentifier: 'non-searchable-object-universal-id',
applicationId: workspaceId,
}),
];
export const mockFlatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata> = {
byId: {
[personNameFieldId]: getFlatFieldMetadataMock({
id: personNameFieldId,
type: FieldMetadataType.FULL_NAME,
icon: 'test-field-icon',
name: 'name',
label: 'Name',
description: "Contact's name",
isCustom: false,
defaultValue: { lastName: "''", firstName: "''" },
objectMetadataId: '20202020-8dec-43d5-b2ff-6eef05095bec',
workspaceId,
universalIdentifier: 'person-name-field-universal-id',
applicationId: workspaceId,
}),
[companyNameFieldId]: getFlatFieldMetadataMock({
id: companyNameFieldId,
type: FieldMetadataType.TEXT,
icon: 'test-field-icon',
name: 'name',
label: 'Name',
description: null,
isCustom: false,
defaultValue: '',
objectMetadataId: '20202020-c03c-45d6-a4b0-04afe1357c5c',
workspaceId,
universalIdentifier: 'company-name-field-universal-id',
applicationId: workspaceId,
}),
[companyDomainNameFieldId]: getFlatFieldMetadataMock({
id: companyDomainNameFieldId,
type: FieldMetadataType.LINKS,
icon: 'test-field-icon',
name: 'domainName',
label: 'Domain Name',
description: null,
isCustom: false,
defaultValue: {
primaryLinkLabel: '',
primaryLinkUrl: '',
secondaryLinks: [],
},
objectMetadataId: '20202020-c03c-45d6-a4b0-04afe1357c5c',
workspaceId,
universalIdentifier: 'company-domain-name-field-universal-id',
applicationId: workspaceId,
}),
[customObjectNameFieldId]: getFlatFieldMetadataMock({
id: customObjectNameFieldId,
type: FieldMetadataType.TEXT,
icon: 'test-field-icon',
name: 'name',
label: 'Name',
description: null,
isCustom: false,
defaultValue: '',
objectMetadataId: '20202020-3d75-4aab-bacd-ee176c5f63ca',
workspaceId,
universalIdentifier: 'custom-object-name-field-universal-id',
applicationId: workspaceId,
}),
[customObjectImageFieldId]: getFlatFieldMetadataMock({
id: customObjectImageFieldId,
type: FieldMetadataType.TEXT,
icon: 'test-field-icon',
name: 'imageIdentifierFieldName',
label: 'Image Identifier Field Name',
description: null,
isCustom: false,
defaultValue: '',
objectMetadataId: '20202020-3d75-4aab-bacd-ee176c5f63ca',
workspaceId,
universalIdentifier: 'custom-object-image-field-universal-id',
applicationId: workspaceId,
}),
},
idByUniversalIdentifier: {},
universalIdentifiersByApplicationId: {},
};
@@ -1,204 +1,290 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock';
import { getMockObjectMetadataItemWithFieldsMaps } from 'src/utils/__test__/get-object-metadata-item-with-fields-maps.mock';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
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';
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
import { type ObjectMetadataInfo } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
const workspaceId = '20202020-0000-0000-0000-000000000000';
export const mockObjectMetadataItemsWithFieldMaps = [
getMockObjectMetadataItemWithFieldsMaps({
id: '20202020-8dec-43d5-b2ff-6eef05095bec',
standardId: '',
nameSingular: 'person',
namePlural: 'people',
labelSingular: 'Person',
labelPlural: 'People',
description: 'A person',
icon: 'test-person-icon',
targetTableName: 'DEPRECATED',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
isAuditLogged: true,
isSearchable: true,
labelIdentifierFieldMetadataId: 'nameFieldMetadataId',
imageIdentifierFieldMetadataId: '',
workspaceId,
indexMetadatas: [],
fieldsById: {
nameFieldMetadataId: getMockFieldMetadataEntity({
workspaceId,
objectMetadataId: '20202020-8dec-43d5-b2ff-6eef05095bec',
id: 'nameFieldMetadataId',
type: FieldMetadataType.FULL_NAME,
icon: 'test-field-icon',
name: 'name',
label: 'Name',
defaultValue: {
lastName: "''",
firstName: "''",
},
description: "Contact's name",
isCustom: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
}),
},
fieldIdByName: {
name: 'nameFieldMetadataId',
},
fieldIdByJoinColumnName: {},
}),
getMockObjectMetadataItemWithFieldsMaps({
id: '20202020-c03c-45d6-a4b0-04afe1357c5c',
standardId: '',
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
description: 'A company',
icon: 'test-company-icon',
targetTableName: 'DEPRECATED',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
isAuditLogged: true,
isSearchable: true,
labelIdentifierFieldMetadataId: 'nameFieldMetadataId',
imageIdentifierFieldMetadataId: '',
workspaceId,
indexMetadatas: [],
fieldsById: {
nameFieldMetadataId: getMockFieldMetadataEntity({
workspaceId,
objectMetadataId: '20202020-c03c-45d6-a4b0-04afe1357c5c',
id: 'nameFieldMetadataId',
type: FieldMetadataType.TEXT,
icon: 'test-field-icon',
name: 'name',
label: 'Name',
defaultValue: '',
isCustom: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
}),
domainNameFieldMetadataId: getMockFieldMetadataEntity({
workspaceId,
objectMetadataId: '20202020-c03c-45d6-a4b0-04afe1357c5c',
id: 'domainNameFieldMetadataId',
type: FieldMetadataType.LINKS,
icon: 'test-field-icon',
name: 'domainName',
label: 'Domain Name',
defaultValue: {
primaryLinkLabel: '',
primaryLinkUrl: '',
secondaryLinks: [],
},
isCustom: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
}),
},
fieldIdByName: {
name: 'nameFieldMetadataId',
domainName: 'domainNameFieldMetadataId',
},
fieldIdByJoinColumnName: {},
}),
getMockObjectMetadataItemWithFieldsMaps({
id: '20202020-3d75-4aab-bacd-ee176c5f63ca',
standardId: '',
nameSingular: 'regular-custom-object',
namePlural: 'regular-custom-objects',
labelSingular: 'Regular Custom Object',
labelPlural: 'Regular Custom Objects',
description: 'A regular custom object',
icon: 'test-regular-custom-object-icon',
targetTableName: 'DEPRECATED',
isCustom: true,
isRemote: false,
isActive: true,
isSystem: false,
isAuditLogged: true,
isSearchable: true,
labelIdentifierFieldMetadataId: 'nameFieldMetadataId',
imageIdentifierFieldMetadataId: 'imageIdentifierFieldMetadataId',
workspaceId,
indexMetadatas: [],
fieldsById: {
nameFieldMetadataId: getMockFieldMetadataEntity({
workspaceId,
objectMetadataId: '20202020-3d75-4aab-bacd-ee176c5f63ca',
id: 'nameFieldMetadataId',
type: FieldMetadataType.TEXT,
icon: 'test-field-icon',
name: 'name',
label: 'Name',
defaultValue: '',
isCustom: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
}),
imageIdentifierFieldMetadataId: getMockFieldMetadataEntity({
workspaceId,
objectMetadataId: '20202020-3d75-4aab-bacd-ee176c5f63ca',
id: 'imageIdentifierFieldMetadataId',
type: FieldMetadataType.TEXT,
icon: 'test-field-icon',
name: 'imageIdentifierFieldName',
label: 'Image Identifier Field Name',
defaultValue: '',
isCustom: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
}),
},
fieldIdByName: {
name: 'nameFieldMetadataId',
imageIdentifierFieldName: 'imageIdentifierFieldMetadataId',
},
fieldIdByJoinColumnName: {},
}),
getMockObjectMetadataItemWithFieldsMaps({
id: '20202020-540c-4397-b872-2a90ea2fb809',
standardId: '',
nameSingular: 'non-searchable-object',
namePlural: 'non-searchable-objects',
labelSingular: '',
labelPlural: '',
description: '',
icon: 'test-non-searchable-object-icon',
targetTableName: 'DEPRECATED',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: true,
isAuditLogged: true,
isSearchable: false,
labelIdentifierFieldMetadataId: '',
imageIdentifierFieldMetadataId: '',
workspaceId,
indexMetadatas: [],
fieldsById: {},
fieldIdByName: {},
fieldIdByJoinColumnName: {},
}),
const personNameFieldId = 'nameFieldMetadataId-person';
const companyNameFieldId = 'nameFieldMetadataId-company';
const companyDomainNameFieldId = 'domainNameFieldMetadataId';
const customObjectNameFieldId = 'nameFieldMetadataId-custom';
const customObjectImageFieldId = 'imageIdentifierFieldMetadataId';
const personFlatObject = getFlatObjectMetadataMock({
id: '20202020-8dec-43d5-b2ff-6eef05095bec',
standardId: '20202020-8dec-43d5-b2ff-6eef05095bec',
nameSingular: 'person',
namePlural: 'people',
labelSingular: 'Person',
labelPlural: 'People',
description: 'A person',
icon: 'test-person-icon',
targetTableName: 'DEPRECATED',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
isAuditLogged: true,
isSearchable: true,
labelIdentifierFieldMetadataId: personNameFieldId,
imageIdentifierFieldMetadataId: null,
workspaceId,
universalIdentifier: '20202020-8dec-43d5-b2ff-6eef05095bec',
fieldMetadataIds: [personNameFieldId],
indexMetadataIds: [],
viewIds: [],
});
const companyFlatObject = getFlatObjectMetadataMock({
id: '20202020-c03c-45d6-a4b0-04afe1357c5c',
standardId: '20202020-c03c-45d6-a4b0-04afe1357c5c',
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
description: 'A company',
icon: 'test-company-icon',
targetTableName: 'DEPRECATED',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
isAuditLogged: true,
isSearchable: true,
labelIdentifierFieldMetadataId: companyNameFieldId,
imageIdentifierFieldMetadataId: null,
workspaceId,
universalIdentifier: '20202020-c03c-45d6-a4b0-04afe1357c5c',
fieldMetadataIds: [companyNameFieldId, companyDomainNameFieldId],
indexMetadataIds: [],
viewIds: [],
});
const customObjectFlatObject = getFlatObjectMetadataMock({
id: '20202020-3d75-4aab-bacd-ee176c5f63ca',
standardId: null,
nameSingular: 'regular-custom-object',
namePlural: 'regular-custom-objects',
labelSingular: 'Regular Custom Object',
labelPlural: 'Regular Custom Objects',
description: 'A regular custom object',
icon: 'test-regular-custom-object-icon',
targetTableName: 'DEPRECATED',
isCustom: true,
isRemote: false,
isActive: true,
isSystem: false,
isAuditLogged: true,
isSearchable: true,
labelIdentifierFieldMetadataId: customObjectNameFieldId,
imageIdentifierFieldMetadataId: customObjectImageFieldId,
workspaceId,
universalIdentifier: '20202020-3d75-4aab-bacd-ee176c5f63ca',
fieldMetadataIds: [customObjectNameFieldId, customObjectImageFieldId],
indexMetadataIds: [],
viewIds: [],
});
const nonSearchableFlatObject = getFlatObjectMetadataMock({
id: '20202020-540c-4397-b872-2a90ea2fb809',
standardId: '20202020-540c-4397-b872-2a90ea2fb809',
nameSingular: 'non-searchable-object',
namePlural: 'non-searchable-objects',
labelSingular: '',
labelPlural: '',
description: '',
icon: 'test-non-searchable-object-icon',
targetTableName: 'DEPRECATED',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: true,
isAuditLogged: true,
isSearchable: false,
labelIdentifierFieldMetadataId: null,
imageIdentifierFieldMetadataId: null,
workspaceId,
universalIdentifier: '20202020-540c-4397-b872-2a90ea2fb809',
fieldMetadataIds: [],
indexMetadataIds: [],
viewIds: [],
});
const personNameField = getFlatFieldMetadataMock({
id: personNameFieldId,
objectMetadataId: personFlatObject.id,
type: FieldMetadataType.FULL_NAME,
icon: 'test-field-icon',
name: 'name',
label: 'Name',
defaultValue: {
lastName: "''",
firstName: "''",
},
description: "Contact's name",
isCustom: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
universalIdentifier: personNameFieldId,
workspaceId,
});
const companyNameField = getFlatFieldMetadataMock({
id: companyNameFieldId,
objectMetadataId: companyFlatObject.id,
type: FieldMetadataType.TEXT,
icon: 'test-field-icon',
name: 'name',
label: 'Name',
defaultValue: '',
isCustom: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
universalIdentifier: companyNameFieldId,
workspaceId,
});
const companyDomainNameField = getFlatFieldMetadataMock({
id: companyDomainNameFieldId,
objectMetadataId: companyFlatObject.id,
type: FieldMetadataType.LINKS,
icon: 'test-field-icon',
name: 'domainName',
label: 'Domain Name',
defaultValue: {
primaryLinkLabel: '',
primaryLinkUrl: '',
secondaryLinks: [],
},
isCustom: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
universalIdentifier: companyDomainNameFieldId,
workspaceId,
});
const customObjectNameField = getFlatFieldMetadataMock({
id: customObjectNameFieldId,
objectMetadataId: customObjectFlatObject.id,
type: FieldMetadataType.TEXT,
icon: 'test-field-icon',
name: 'name',
label: 'Name',
defaultValue: '',
isCustom: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
universalIdentifier: customObjectNameFieldId,
workspaceId,
});
const customObjectImageField = getFlatFieldMetadataMock({
id: customObjectImageFieldId,
objectMetadataId: customObjectFlatObject.id,
type: FieldMetadataType.TEXT,
icon: 'test-field-icon',
name: 'imageIdentifierFieldName',
label: 'Image Identifier Field Name',
defaultValue: '',
isCustom: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: true,
universalIdentifier: customObjectImageFieldId,
workspaceId,
});
const ALL_FLAT_OBJECTS = [
personFlatObject,
companyFlatObject,
customObjectFlatObject,
nonSearchableFlatObject,
];
const ALL_FLAT_FIELDS = [
personNameField,
companyNameField,
companyDomainNameField,
customObjectNameField,
customObjectImageField,
];
export const mockFlatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata> =
ALL_FLAT_OBJECTS.reduce(
(acc, object) => ({
...acc,
byId: {
...acc.byId,
[object.id]: object,
},
idByUniversalIdentifier: {
...acc.idByUniversalIdentifier,
[object.universalIdentifier]: object.id,
},
}),
createEmptyFlatEntityMaps() as FlatEntityMaps<FlatObjectMetadata>,
);
export const mockFlatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata> =
ALL_FLAT_FIELDS.reduce(
(acc, field) => ({
...acc,
byId: {
...acc.byId,
[field.id]: field,
},
idByUniversalIdentifier: {
...acc.idByUniversalIdentifier,
[field.universalIdentifier]: field.id,
},
}),
createEmptyFlatEntityMaps() as FlatEntityMaps<FlatFieldMetadata>,
);
export const mockObjectIdByNameSingular: Record<string, string> =
ALL_FLAT_OBJECTS.reduce(
(acc, object) => ({
...acc,
[object.nameSingular]: object.id,
}),
{} as Record<string, string>,
);
export const getMockObjectMetadataInfo = (
nameSingular: string,
): ObjectMetadataInfo => {
const objectId = mockObjectIdByNameSingular[nameSingular];
const flatObjectMetadata = mockFlatObjectMetadataMaps.byId[objectId];
if (!flatObjectMetadata) {
throw new Error(
`Object metadata not found for nameSingular: ${nameSingular}`,
);
}
return {
flatObjectMetadata,
flatObjectMetadataMaps: mockFlatObjectMetadataMaps,
flatFieldMetadataMaps: mockFlatFieldMetadataMaps,
};
};
export const mockPersonObjectMetadataInfo = getMockObjectMetadataInfo('person');
export const mockCompanyObjectMetadataInfo =
getMockObjectMetadataInfo('company');
export const mockCustomObjectMetadataInfo = getMockObjectMetadataInfo(
'regular-custom-object',
);
export const mockNonSearchableObjectMetadataInfo = getMockObjectMetadataInfo(
'non-searchable-object',
);
@@ -8,7 +8,7 @@ import { buildCreatedByFromFullNameMetadata } from 'src/engine/core-modules/acto
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { buildFieldMapsForObject } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-for-object.util';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@@ -55,8 +55,10 @@ export class CreatedByFromAuthContextService {
: undefined;
const fieldIdByName = objectMetadata
? buildFieldMapsForObject(flatFieldMetadataMaps, objectMetadata.id)
.fieldIdByName
? buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
objectMetadata,
).fieldIdByName
: {};
this.logger.log(
@@ -1,27 +1,46 @@
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';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { objectRecordChangedValues } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values';
import { getMockObjectMetadataItemWithFieldsMaps } from 'src/utils/__test__/get-object-metadata-item-with-fields-maps.mock';
const mockObjectMetadata = getMockObjectMetadataItemWithFieldsMaps({
const mockObjectMetadata = {
id: '1',
icon: 'Icon123',
nameSingular: 'Object',
namePlural: 'Objects',
labelSingular: 'Object',
labelPlural: 'Objects',
description: 'Test object metadata',
targetTableName: 'test_table',
workspaceId: '1',
fieldsById: {},
fieldIdByName: {},
universalIdentifier: '1',
isSystem: false,
isCustom: false,
isActive: true,
isRemote: false,
isAuditLogged: true,
isSearchable: true,
indexMetadatas: [],
fieldIdByJoinColumnName: {},
});
indexMetadataIds: [],
fieldMetadataIds: [],
viewIds: [],
applicationId: null,
isLabelSyncedWithName: false,
createdAt: new Date(),
updatedAt: new Date(),
shortcut: null,
description: null,
standardOverrides: null,
isUIReadOnly: false,
standardId: null,
labelIdentifierFieldMetadataId: null,
imageIdentifierFieldMetadataId: null,
duplicateCriteria: null,
} as FlatObjectMetadata;
const mockFlatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata> = {
byId: {},
idByUniversalIdentifier: {},
universalIdentifiersByApplicationId: {},
};
describe('objectRecordChangedValues', () => {
it('detects changes in scalar values correctly', () => {
@@ -40,6 +59,7 @@ describe('objectRecordChangedValues', () => {
oldRecord,
newRecord,
mockObjectMetadata,
mockFlatFieldMetadataMaps,
);
expect(result).toEqual({
@@ -61,6 +81,7 @@ describe('objectRecordChangedValues', () => {
oldRecord,
newRecord,
mockObjectMetadata,
mockFlatFieldMetadataMaps,
);
expect(result).toEqual({});
@@ -82,6 +103,7 @@ describe('objectRecordChangedValues', () => {
oldRecord,
newRecord,
mockObjectMetadata,
mockFlatFieldMetadataMaps,
);
expect(result).toEqual({});
@@ -111,6 +133,7 @@ describe('objectRecordChangedValues', () => {
oldRecord,
newRecord,
mockObjectMetadata,
mockFlatFieldMetadataMaps,
);
expect(result).toEqual(expectedChanges);
@@ -2,10 +2,13 @@ import deepEqual from 'deep-equal';
import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata';
import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
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';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
const isWorkflowVersionStepsOrTrigger = (
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
objectMetadataItem: FlatObjectMetadata,
key: string,
) => {
return (
@@ -15,7 +18,7 @@ const isWorkflowVersionStepsOrTrigger = (
};
const isWorkflowAutomatedTriggerSettings = (
objectMetadataItem: Pick<ObjectMetadataItemWithFieldMaps, 'standardId'>,
objectMetadataItem: Pick<FlatObjectMetadata, 'standardId'>,
key: string,
) => {
return (
@@ -27,12 +30,18 @@ const isWorkflowAutomatedTriggerSettings = (
export const objectRecordChangedValues = (
oldRecord: Partial<ObjectRecord>,
newRecord: Partial<ObjectRecord>,
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
objectMetadataItem: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
) => {
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
objectMetadataItem,
);
return Object.keys(newRecord).reduce(
(acc, key) => {
const field =
objectMetadataItem.fieldsById[objectMetadataItem.fieldIdByName[key]];
const fieldId = fieldIdByName[key];
const field = fieldId ? flatFieldMetadataMaps.byId[fieldId] : undefined;
const oldRecordValue = oldRecord[key];
const newRecordValue = newRecord[key];
@@ -4,10 +4,16 @@ import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { OpenApiController } from 'src/engine/core-modules/open-api/open-api.controller';
import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
@Module({
imports: [ObjectMetadataModule, AuthModule, FeatureFlagModule],
imports: [
ObjectMetadataModule,
AuthModule,
FeatureFlagModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
controllers: [OpenApiController],
providers: [OpenApiService],
})
@@ -4,6 +4,7 @@ import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
describe('OpenApiService', () => {
@@ -29,6 +30,12 @@ describe('OpenApiService', () => {
provide: FeatureFlagService,
useValue: {},
},
{
provide: WorkspaceManyOrAllFlatEntityMapsCacheService,
useValue: {
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn(),
},
},
],
}).compile();
@@ -45,10 +45,9 @@ import {
getUpdateOneResponse200,
} from 'src/engine/core-modules/open-api/utils/responses.utils';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
import { shouldExcludeFromWorkspaceApi } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/should-exclude-from-workspace-api.util';
import { getServerUrl } from 'src/utils/get-server-url';
@@ -58,7 +57,7 @@ export class OpenApiService {
constructor(
private readonly accessTokenService: AccessTokenService,
private readonly twentyConfigService: TwentyConfigService,
private readonly objectMetadataService: ObjectMetadataServiceV2,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly featureFlagService: FeatureFlagService,
) {}
@@ -75,15 +74,28 @@ export class OpenApiService {
}
}
private async getObjectMetadataItems(workspace: WorkspaceEntity) {
return await this.objectMetadataService.findManyWithinWorkspace(
workspace.id,
{
order: {
namePlural: 'ASC',
private async getFlatObjectMetadataArray(workspaceId: string) {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
},
);
const flatObjectMetadataArray = Object.values(
flatObjectMetadataMaps.byId,
).filter(isDefined);
flatObjectMetadataArray.sort((a, b) =>
a.namePlural.localeCompare(b.namePlural),
);
return {
flatObjectMetadataArray,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
};
}
async generateCoreSchema(request: Request): Promise<OpenAPIV3_1.Document> {
@@ -105,33 +117,65 @@ export class OpenApiService {
return schema;
}
const objectMetadataItems = await this.getObjectMetadataItems(workspace);
const {
flatObjectMetadataArray,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
} = await this.getFlatObjectMetadataArray(workspace.id);
if (!objectMetadataItems.length) {
if (!flatObjectMetadataArray.length) {
return schema;
}
const workspaceFeatureFlagsMap =
await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspace.id);
const filteredObjectMetadataItems = objectMetadataItems.filter((item) => {
return !shouldExcludeFromWorkspaceApi(
item,
standardObjectMetadataDefinitions,
workspaceFeatureFlagsMap,
);
});
const filteredObjectMetadataItems = flatObjectMetadataArray.filter(
(item) => {
return !shouldExcludeFromWorkspaceApi(
item,
standardObjectMetadataDefinitions,
workspaceFeatureFlagsMap,
);
},
);
schema.paths = filteredObjectMetadataItems.reduce((paths, item) => {
paths[`/${item.namePlural}`] = computeManyResultPath(item);
paths[`/batch/${item.namePlural}`] = computeBatchPath(item);
paths[`/${item.namePlural}/{id}`] = computeSingleResultPath(item);
paths[`/${item.namePlural}/duplicates`] =
computeDuplicatesResultPath(item);
paths[`/restore/${item.namePlural}/{id}`] =
computeRestoreOneResultPath(item);
paths[`/restore/${item.namePlural}`] = computeRestoreManyResultPath(item);
paths[`/${item.namePlural}/merge`] = computeMergeManyResultPath(item);
paths[`/${item.namePlural}`] = computeManyResultPath(
item,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
paths[`/batch/${item.namePlural}`] = computeBatchPath(
item,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
paths[`/${item.namePlural}/{id}`] = computeSingleResultPath(
item,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
paths[`/${item.namePlural}/duplicates`] = computeDuplicatesResultPath(
item,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
paths[`/restore/${item.namePlural}/{id}`] = computeRestoreOneResultPath(
item,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
paths[`/restore/${item.namePlural}`] = computeRestoreManyResultPath(
item,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
paths[`/${item.namePlural}/merge`] = computeMergeManyResultPath(
item,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
return paths;
}, schema.paths as OpenAPIV3_1.PathsObject);
@@ -143,19 +187,34 @@ export class OpenApiService {
DatabaseEventAction.CREATED,
item.nameSingular,
)
] = computeWebhooks(DatabaseEventAction.CREATED, item);
] = computeWebhooks(
DatabaseEventAction.CREATED,
item,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
paths[
this.createWebhookEventName(
DatabaseEventAction.UPDATED,
item.nameSingular,
)
] = computeWebhooks(DatabaseEventAction.UPDATED, item);
] = computeWebhooks(
DatabaseEventAction.UPDATED,
item,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
paths[
this.createWebhookEventName(
DatabaseEventAction.DELETED,
item.nameSingular,
)
] = computeWebhooks(DatabaseEventAction.DELETED, item);
] = computeWebhooks(
DatabaseEventAction.DELETED,
item,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
return paths;
},
@@ -167,7 +226,11 @@ export class OpenApiService {
schema.components = {
...schema.components, // components.securitySchemes is defined in base Schema
schemas: computeSchemaComponents(filteredObjectMetadataItems),
schemas: computeSchemaComponents(
filteredObjectMetadataItems,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
),
parameters: computeParameterComponents(),
responses: {
'400': get400ErrorResponses(),
@@ -347,7 +410,7 @@ export class OpenApiService {
metadata.map((item) => ({
nameSingular: item.nameSingular,
namePlural: item.namePlural,
})) as ObjectMetadataEntity[],
})) as FlatObjectMetadata[],
);
return schema;
@@ -5,15 +5,48 @@ import { FieldMetadataType, NumberDataType } from 'twenty-shared/types';
import { objectMetadataItemMock } from 'src/engine/api/__mocks__/object-metadata-item.mock';
import { computeSchemaComponents } from 'src/engine/core-modules/open-api/utils/components.utils';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
describe('computeSchemaComponents', () => {
faker.seed(1);
it('should compute schema components', () => {
const flatObjectMetadata: FlatObjectMetadata = {
...objectMetadataItemMock,
universalIdentifier: 'objectName',
fieldMetadataIds: objectMetadataItemMock.fields.map((f) => f.id),
} as any;
const flatFieldMetadataMaps = {
byId: Object.fromEntries(
objectMetadataItemMock.fields.map((f) => [f.id, f as any]),
),
idByUniversalIdentifier: {},
universalIdentifiersByApplicationId: {},
};
const relationTargetObjectMetadata: FlatObjectMetadata = {
id: 'relationTargetObjectId',
nameSingular: 'relationTargetObject',
namePlural: 'relationTargetObjects',
universalIdentifier: 'relationTargetObject',
fieldMetadataIds: [],
} as any;
const flatObjectMetadataMaps = {
byId: {
[flatObjectMetadata.id]: flatObjectMetadata,
[relationTargetObjectMetadata.id]: relationTargetObjectMetadata,
},
idByUniversalIdentifier: {},
universalIdentifiersByApplicationId: {},
};
expect(
computeSchemaComponents([
objectMetadataItemMock,
] as ObjectMetadataEntity[]),
computeSchemaComponents(
[flatObjectMetadata],
flatObjectMetadataMaps,
flatFieldMetadataMaps,
),
).toMatchInlineSnapshot(`
{
"ObjectName": {
@@ -819,17 +852,37 @@ describe('computeSchemaComponents', () => {
];
it.each(testsCases)('$title', ({ context: field }) => {
const flatObjectMetadata: FlatObjectMetadata = {
targetTableName: 'testingObject',
id: 'mockObjectId',
nameSingular: 'objectName',
namePlural: 'objectsName',
universalIdentifier: 'objectName',
fieldMetadataIds: [field.id],
} as any;
const flatFieldMetadataMaps = {
byId: {
[field.id]: field as any,
},
idByUniversalIdentifier: {},
universalIdentifiersByApplicationId: {},
};
const flatObjectMetadataMaps = {
byId: {
[flatObjectMetadata.id]: flatObjectMetadata,
},
idByUniversalIdentifier: {},
universalIdentifiersByApplicationId: {},
};
expect(
computeSchemaComponents([
{
targetTableName: 'testingObject',
id: 'mockObjectId',
nameSingular: 'objectName',
namePlural: 'objectsName',
//@ts-expect-error Passing partial FieldMetadataEntity array
fields: [field],
},
]),
computeSchemaComponents(
[flatObjectMetadata],
flatObjectMetadataMaps,
flatFieldMetadataMaps,
),
).toMatchSnapshot();
});
});
@@ -17,6 +17,10 @@ import {
computeStartingAfterParameters,
computeUpsertParameters,
} from 'src/engine/core-modules/open-api/utils/parameters.utils';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { convertObjectMetadataToSchemaProperties } from 'src/engine/utils/convert-object-metadata-to-schema-properties.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
@@ -31,12 +35,18 @@ type Properties = {
type OpenApiExample = Record<string, FieldMetadataDefaultValue>;
const getSchemaComponentsExample = (
item: ObjectMetadataEntity,
item: FlatObjectMetadata,
flatFieldMetadatas: FlatFieldMetadata[],
): OpenApiExample => {
return item.fields.reduce((node, field) => {
return flatFieldMetadatas.reduce((node, field) => {
// If field is required
if (!field.isNullable && field.defaultValue === null) {
return { ...node, [field.name]: generateRandomFieldValue({ field }) };
return {
...node,
[field.name]: generateRandomFieldValue({
field,
}),
};
}
switch (field.type) {
@@ -60,7 +70,9 @@ const getSchemaComponentsExample = (
case FieldMetadataType.PHONES: {
return {
...node,
[field.name]: generateRandomFieldValue({ field }),
[field.name]: generateRandomFieldValue({
field,
}),
};
}
@@ -72,9 +84,13 @@ const getSchemaComponentsExample = (
};
const getSchemaComponentsRelationProperties = (
item: ObjectMetadataEntity,
flatFieldMetadatas: FlatFieldMetadata[],
flatObjectMetadataMaps: Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps'
>['flatObjectMetadataMaps'],
): Properties => {
return item.fields.reduce((node, field) => {
return flatFieldMetadatas.reduce((node, field) => {
if (field.type !== FieldMetadataType.RELATION) {
return node;
}
@@ -82,13 +98,20 @@ const getSchemaComponentsRelationProperties = (
let itemProperty = {} as Property;
if (isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION)) {
const targetObjectMetadata =
flatObjectMetadataMaps.byId[field.relationTargetObjectMetadataId];
if (!targetObjectMetadata) {
return node;
}
if (field.settings?.relationType === RelationType.MANY_TO_ONE) {
itemProperty = {
type: 'object',
oneOf: [
{
$ref: `#/components/schemas/${capitalize(
field.relationTargetObjectMetadata.nameSingular,
targetObjectMetadata.nameSingular,
)}ForResponse`,
},
],
@@ -98,7 +121,7 @@ const getSchemaComponentsRelationProperties = (
type: 'array',
items: {
$ref: `#/components/schemas/${capitalize(
field.relationTargetObjectMetadata.nameSingular,
targetObjectMetadata.nameSingular,
)}ForResponse`,
},
};
@@ -117,8 +140,10 @@ const getSchemaComponentsRelationProperties = (
}, {} as Properties);
};
const getRequiredFields = (item: ObjectMetadataEntity): string[] => {
return item.fields.reduce((required, field) => {
const getRequiredFields = (
flatFieldMetadatas: FlatFieldMetadata[],
): string[] => {
return flatFieldMetadatas.reduce((required, field) => {
if (!field.isNullable && field.defaultValue === null) {
required.push(field.name);
@@ -131,10 +156,17 @@ const getRequiredFields = (item: ObjectMetadataEntity): string[] => {
const computeSchemaComponent = ({
item,
flatFieldMetadatas,
flatObjectMetadataMaps,
forResponse,
forUpdate,
}: {
item: ObjectMetadataEntity;
item: FlatObjectMetadata;
flatFieldMetadatas: FlatFieldMetadata[];
flatObjectMetadataMaps: Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps'
>['flatObjectMetadataMaps'];
forResponse: boolean;
forUpdate: boolean;
}): OpenAPIV3_1.SchemaObject => {
@@ -142,20 +174,31 @@ const computeSchemaComponent = ({
const withRequiredFields = !forResponse && !forUpdate;
// Create a temporary object that looks like ObjectMetadataEntity for the converter
const tempItem = {
...item,
fields: flatFieldMetadatas,
} as unknown as ObjectMetadataEntity;
const result: OpenAPIV3_1.SchemaObject = {
type: 'object',
description: item.description ?? undefined,
properties: convertObjectMetadataToSchemaProperties({
item,
item: tempItem,
forResponse,
}) as Properties,
...(!forResponse ? { example: getSchemaComponentsExample(item) } : {}),
...(!forResponse
? { example: getSchemaComponentsExample(item, flatFieldMetadatas) }
: {}),
};
if (withRelations) {
result.properties = {
...result.properties,
...getSchemaComponentsRelationProperties(item),
...getSchemaComponentsRelationProperties(
flatFieldMetadatas,
flatObjectMetadataMaps,
),
};
}
@@ -163,7 +206,7 @@ const computeSchemaComponent = ({
return result;
}
const requiredFields = getRequiredFields(item);
const requiredFields = getRequiredFields(flatFieldMetadatas);
if (requiredFields?.length) {
result.required = requiredFields;
@@ -173,24 +216,44 @@ const computeSchemaComponent = ({
};
export const computeSchemaComponents = (
objectMetadataItems: ObjectMetadataEntity[],
flatObjectMetadataItems: FlatObjectMetadata[],
flatObjectMetadataMaps: Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps'
>['flatObjectMetadataMaps'],
flatFieldMetadataMaps: Pick<
AllFlatEntityMaps,
'flatFieldMetadataMaps'
>['flatFieldMetadataMaps'],
): Record<string, OpenAPIV3_1.SchemaObject> => {
return objectMetadataItems.reduce(
return flatObjectMetadataItems.reduce(
(schemas, item) => {
const flatFieldMetadatas =
findManyFlatEntityByIdInFlatEntityMapsOrThrow<FlatFieldMetadata>({
flatEntityMaps: flatFieldMetadataMaps,
flatEntityIds: item.fieldMetadataIds,
});
schemas[capitalize(item.nameSingular)] = computeSchemaComponent({
item,
flatFieldMetadatas,
flatObjectMetadataMaps,
forResponse: false,
forUpdate: false,
});
schemas[capitalize(item.nameSingular) + 'ForUpdate'] =
computeSchemaComponent({
item,
flatFieldMetadatas,
flatObjectMetadataMaps,
forResponse: false,
forUpdate: true,
});
schemas[capitalize(item.nameSingular) + 'ForResponse'] =
computeSchemaComponent({
item,
flatFieldMetadatas,
flatObjectMetadataMaps,
forResponse: true,
forUpdate: false,
});
@@ -1,10 +1,10 @@
import { type OpenAPIV3_1 } from 'openapi-types';
import { capitalize } from 'twenty-shared/utils';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export const computeSchemaTags = (
items: ObjectMetadataEntity[],
items: Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>[],
): OpenAPIV3_1.TagObject[] => {
const results = [{ name: 'General', description: 'General requests' }];
@@ -2,11 +2,20 @@ import { type OpenAPIV3_1 } from 'openapi-types';
import { capitalize } from 'twenty-shared/utils';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export const computeWebhooks = (
type: DatabaseEventAction,
item: ObjectMetadataEntity,
item: Pick<FlatObjectMetadata, 'nameSingular'>,
_flatObjectMetadataMaps: Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps'
>['flatObjectMetadataMaps'],
_flatFieldMetadataMaps: Pick<
AllFlatEntityMaps,
'flatFieldMetadataMaps'
>['flatFieldMetadataMaps'],
): OpenAPIV3_1.PathItemObject => {
const updatedFields = {
type: 'array',
@@ -6,11 +6,12 @@ import { v4 } from 'uuid';
import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
export const generateRandomFieldValue = ({
field,
}: {
field: FieldMetadataEntity;
field: FieldMetadataEntity | FlatFieldMetadata;
}): FieldMetadataDefaultValue => {
switch (field.type) {
case FieldMetadataType.UUID: {
@@ -23,10 +23,19 @@ import {
getUpdateManyResponse200,
getUpdateOneResponse200,
} from 'src/engine/core-modules/open-api/utils/responses.utils';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export const computeBatchPath = (
item: ObjectMetadataEntity,
item: Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
_flatObjectMetadataMaps: Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps'
>['flatObjectMetadataMaps'],
_flatFieldMetadataMaps: Pick<
AllFlatEntityMaps,
'flatFieldMetadataMaps'
>['flatFieldMetadataMaps'],
): OpenAPIV3_1.PathItemObject => {
return {
post: {
@@ -48,7 +57,15 @@ export const computeBatchPath = (
};
export const computeManyResultPath = (
item: ObjectMetadataEntity,
item: Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
_flatObjectMetadataMaps: Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps'
>['flatObjectMetadataMaps'],
_flatFieldMetadataMaps: Pick<
AllFlatEntityMaps,
'flatFieldMetadataMaps'
>['flatFieldMetadataMaps'],
): OpenAPIV3_1.PathItemObject => {
return {
get: {
@@ -118,7 +135,15 @@ export const computeManyResultPath = (
};
export const computeSingleResultPath = (
item: ObjectMetadataEntity,
item: Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
_flatObjectMetadataMaps: Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps'
>['flatObjectMetadataMaps'],
_flatFieldMetadataMaps: Pick<
AllFlatEntityMaps,
'flatFieldMetadataMaps'
>['flatFieldMetadataMaps'],
): OpenAPIV3_1.PathItemObject => {
return {
get: {
@@ -189,7 +214,15 @@ export const computeOpenApiPath = (
};
export const computeDuplicatesResultPath = (
item: ObjectMetadataEntity,
item: Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
_flatObjectMetadataMaps: Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps'
>['flatObjectMetadataMaps'],
_flatFieldMetadataMaps: Pick<
AllFlatEntityMaps,
'flatFieldMetadataMaps'
>['flatFieldMetadataMaps'],
): OpenAPIV3_1.PathItemObject => {
return {
post: {
@@ -209,7 +242,15 @@ export const computeDuplicatesResultPath = (
};
export const computeRestoreOneResultPath = (
item: ObjectMetadataEntity,
item: Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
_flatObjectMetadataMaps: Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps'
>['flatObjectMetadataMaps'],
_flatFieldMetadataMaps: Pick<
AllFlatEntityMaps,
'flatFieldMetadataMaps'
>['flatFieldMetadataMaps'],
): OpenAPIV3_1.PathItemObject => {
return {
patch: {
@@ -230,7 +271,15 @@ export const computeRestoreOneResultPath = (
};
export const computeRestoreManyResultPath = (
item: ObjectMetadataEntity,
item: Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
_flatObjectMetadataMaps: Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps'
>['flatObjectMetadataMaps'],
_flatFieldMetadataMaps: Pick<
AllFlatEntityMaps,
'flatFieldMetadataMaps'
>['flatFieldMetadataMaps'],
): OpenAPIV3_1.PathItemObject => {
return {
patch: {
@@ -251,7 +300,15 @@ export const computeRestoreManyResultPath = (
};
export const computeMergeManyResultPath = (
item: ObjectMetadataEntity,
item: Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
_flatObjectMetadataMaps: Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps'
>['flatObjectMetadataMaps'],
_flatFieldMetadataMaps: Pick<
AllFlatEntityMaps,
'flatFieldMetadataMaps'
>['flatFieldMetadataMaps'],
): OpenAPIV3_1.PathItemObject => {
return {
patch: {
@@ -1,9 +1,9 @@
import { capitalize } from 'twenty-shared/utils';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export const getFindManyResponse200 = (
item: Pick<ObjectMetadataEntity, 'nameSingular' | 'namePlural'>,
item: Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
fromMetadata = false,
) => {
const schemaRef = `#/components/schemas/${capitalize(
@@ -55,7 +55,7 @@ export const getFindManyResponse200 = (
};
export const getFindOneResponse200 = (
item: Pick<ObjectMetadataEntity, 'nameSingular'>,
item: Pick<FlatObjectMetadata, 'nameSingular'>,
) => {
const schemaRef = `#/components/schemas/${capitalize(item.nameSingular)}ForResponse`;
@@ -82,7 +82,7 @@ export const getFindOneResponse200 = (
};
export const getRestoreOneResponse200 = (
item: Pick<ObjectMetadataEntity, 'nameSingular'>,
item: Pick<FlatObjectMetadata, 'nameSingular'>,
) => {
const schemaRef = `#/components/schemas/${capitalize(item.nameSingular)}ForResponse`;
@@ -109,7 +109,7 @@ export const getRestoreOneResponse200 = (
};
export const getRestoreManyResponse200 = (
item: Pick<ObjectMetadataEntity, 'nameSingular' | 'namePlural'>,
item: Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
) => {
const schemaRef = `#/components/schemas/${capitalize(
item.nameSingular,
@@ -141,7 +141,7 @@ export const getRestoreManyResponse200 = (
};
export const getCreateOneResponse201 = (
item: Pick<ObjectMetadataEntity, 'nameSingular'>,
item: Pick<FlatObjectMetadata, 'nameSingular'>,
fromMetadata = false,
) => {
const one = fromMetadata ? 'One' : '';
@@ -173,7 +173,7 @@ export const getCreateOneResponse201 = (
};
export const getCreateManyResponse201 = (
item: Pick<ObjectMetadataEntity, 'nameSingular' | 'namePlural'>,
item: Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
) => {
const schemaRef = `#/components/schemas/${capitalize(
item.nameSingular,
@@ -205,7 +205,7 @@ export const getCreateManyResponse201 = (
};
export const getUpdateOneResponse200 = (
item: Pick<ObjectMetadataEntity, 'nameSingular'>,
item: Pick<FlatObjectMetadata, 'nameSingular'>,
fromMetadata = false,
) => {
const one = fromMetadata ? 'One' : '';
@@ -234,7 +234,7 @@ export const getUpdateOneResponse200 = (
};
export const getDeleteManyResponse200 = (
item: Pick<ObjectMetadataEntity, 'namePlural'>,
item: Pick<FlatObjectMetadata, 'namePlural'>,
) => {
return {
description: 'Successful operation',
@@ -268,7 +268,7 @@ export const getDeleteManyResponse200 = (
};
export const getUpdateManyResponse200 = (
item: Pick<ObjectMetadataEntity, 'namePlural' | 'nameSingular'>,
item: Pick<FlatObjectMetadata, 'namePlural' | 'nameSingular'>,
) => {
const schemaRef = `#/components/schemas/${capitalize(item.nameSingular)}ForResponse`;
@@ -298,7 +298,7 @@ export const getUpdateManyResponse200 = (
};
export const getDeleteResponse200 = (
item: Pick<ObjectMetadataEntity, 'nameSingular'>,
item: Pick<FlatObjectMetadata, 'nameSingular'>,
fromMetadata = false,
) => {
const one = fromMetadata ? 'One' : '';
@@ -391,7 +391,7 @@ export const getJsonResponse = () => {
};
export const getFindDuplicatesResponse200 = (
item: Pick<ObjectMetadataEntity, 'nameSingular'>,
item: Pick<FlatObjectMetadata, 'nameSingular'>,
) => {
const schemaRef = `#/components/schemas/${capitalize(
item.nameSingular,
@@ -441,7 +441,7 @@ export const getFindDuplicatesResponse200 = (
};
export const getMergeManyResponse200 = (
item: Pick<ObjectMetadataEntity, 'nameSingular' | 'namePlural'>,
item: Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
) => {
const schemaRef = `#/components/schemas/${capitalize(
item.nameSingular,
@@ -1,8 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'class-validator';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { FieldActorSource } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import {
RecordCrudException,
@@ -13,11 +13,11 @@ import { getSelectedColumnsFromRestrictedFields } from 'src/engine/core-modules/
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class CreateRecordService {
private readonly logger = new Logger(CreateRecordService.name);
@@ -25,7 +25,6 @@ export class CreateRecordService {
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly recordPositionService: RecordPositionService,
private readonly recordInputTransformerService: RecordInputTransformerService,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
) {}
async execute(params: CreateRecordParams): Promise<ToolOutput> {
@@ -48,16 +47,30 @@ export class CreateRecordService {
rolePermissionConfig,
);
const { objectMetadataItemWithFieldsMaps } =
await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps(
objectName,
workspaceId,
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular,
} = repository.internalContext;
const objectId = objectIdByNameSingular[objectName];
if (!isDefined(objectId)) {
throw new RecordCrudException(
`Object ${objectName} not found`,
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: objectId,
});
if (
!canObjectBeManagedByWorkflow({
nameSingular: objectMetadataItemWithFieldsMaps.nameSingular,
isSystem: objectMetadataItemWithFieldsMaps.isSystem,
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
@@ -68,34 +81,39 @@ export class CreateRecordService {
const position = await this.recordPositionService.buildRecordPosition({
value: 'first',
objectMetadata: objectMetadataItemWithFieldsMaps,
objectMetadata: flatObjectMetadata,
workspaceId,
});
const { fieldIdByName, fieldIdByJoinColumnName } =
buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
flatObjectMetadata,
);
const validObjectRecord = Object.fromEntries(
Object.entries(objectRecord).filter(
([key]) =>
isDefined(objectMetadataItemWithFieldsMaps.fieldIdByName[key]) ||
isDefined(
objectMetadataItemWithFieldsMaps.fieldIdByJoinColumnName[key],
),
isDefined(fieldIdByName[key]) ||
isDefined(fieldIdByJoinColumnName[key]),
),
);
const transformedObjectRecord =
await this.recordInputTransformerService.process({
recordInput: validObjectRecord,
objectMetadataMapItem: objectMetadataItemWithFieldsMaps,
flatObjectMetadata,
flatFieldMetadataMaps,
});
const restrictedFields =
repository.objectRecordsPermissions?.[
objectMetadataItemWithFieldsMaps.id
]?.restrictedFields;
repository.objectRecordsPermissions?.[flatObjectMetadata.id]
?.restrictedFields;
const selectedColumns = getSelectedColumnsFromRestrictedFields(
restrictedFields,
objectMetadataItemWithFieldsMaps,
flatObjectMetadata,
flatFieldMetadataMaps,
);
const insertResult = await repository.insert(
@@ -9,17 +9,15 @@ import {
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { type DeleteRecordParams } from 'src/engine/core-modules/record-crud/types/delete-record-params.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class DeleteRecordService {
private readonly logger = new Logger(DeleteRecordService.name);
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
) {}
async execute(params: DeleteRecordParams): Promise<ToolOutput> {
@@ -55,16 +53,27 @@ export class DeleteRecordService {
rolePermissionConfig,
);
const { objectMetadataItemWithFieldsMaps } =
await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps(
objectName,
workspaceId,
const { flatObjectMetadataMaps, objectIdByNameSingular } =
repository.internalContext;
const objectId = objectIdByNameSingular[objectName];
if (!isDefined(objectId)) {
throw new RecordCrudException(
`Object ${objectName} not found`,
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: objectId,
});
if (
!canObjectBeManagedByWorkflow({
nameSingular: objectMetadataItemWithFieldsMaps.nameSingular,
isSystem: objectMetadataItemWithFieldsMaps.isSystem,
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
@@ -3,6 +3,7 @@ import { Injectable, Logger } from '@nestjs/common';
import isEmpty from 'lodash.isempty';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { OrderByDirection } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type ObjectLiteral } from 'typeorm';
import {
@@ -12,23 +13,27 @@ import {
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils';
import {
RecordCrudException,
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { type FindRecordsParams } from 'src/engine/core-modules/record-crud/types/find-records-params.type';
import { FindRecordsResult } from 'src/engine/core-modules/record-crud/types/find-records-result.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class FindRecordsService {
private readonly logger = new Logger(FindRecordsService.name);
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
) {}
async execute(
@@ -60,15 +65,30 @@ export class FindRecordsService {
rolePermissionConfig,
);
const { objectMetadataItemWithFieldsMaps, objectMetadataMaps } =
await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps(
objectName,
workspaceId,
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular,
} = repository.internalContext;
const objectId = objectIdByNameSingular[objectName];
if (!isDefined(objectId)) {
throw new RecordCrudException(
`Object ${objectName} not found`,
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: objectId,
});
const graphqlQueryParser = new GraphqlQueryParser(
objectMetadataItemWithFieldsMaps,
objectMetadataMaps,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
const records = await this.getObjectRecords({
@@ -79,7 +99,8 @@ export class FindRecordsService {
offset,
repository,
graphqlQueryParser,
objectMetadataItemWithFieldsMaps,
flatObjectMetadata,
flatFieldMetadataMaps,
});
const totalCount = await this.getTotalCount({
@@ -87,7 +108,8 @@ export class FindRecordsService {
filter,
repository,
graphqlQueryParser,
objectMetadataItemWithFieldsMaps,
flatObjectMetadata,
flatFieldMetadataMaps,
});
this.logger.log(`Found ${records.length} records in ${objectName}`);
@@ -115,10 +137,11 @@ export class FindRecordsService {
private applyRestrictedFieldsToQueryBuilder<T extends ObjectLiteral>(
queryBuilder: WorkspaceSelectQueryBuilder<T>,
repository: WorkspaceRepository<T>,
objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps,
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
): WorkspaceSelectQueryBuilder<T> {
const restrictedFields =
repository.objectRecordsPermissions?.[objectMetadataItemWithFieldsMaps.id]
repository.objectRecordsPermissions?.[flatObjectMetadata.id]
?.restrictedFields;
if (!restrictedFields || isEmpty(restrictedFields)) {
@@ -128,7 +151,8 @@ export class FindRecordsService {
const selectableFields = getAllSelectableColumnNames({
restrictedFields,
objectMetadata: {
objectMetadataMapItem: objectMetadataItemWithFieldsMaps,
objectMetadataMapItem: flatObjectMetadata,
flatFieldMetadataMaps,
},
});
@@ -146,7 +170,8 @@ export class FindRecordsService {
offset,
repository,
graphqlQueryParser,
objectMetadataItemWithFieldsMaps,
flatObjectMetadata,
flatFieldMetadataMaps,
}: {
objectName: string;
filter:
@@ -158,7 +183,8 @@ export class FindRecordsService {
offset: number;
repository: WorkspaceRepository<T>;
graphqlQueryParser: GraphqlQueryParser;
objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): Promise<T[]> {
const queryBuilder = repository.createQueryBuilder(objectName);
@@ -183,7 +209,8 @@ export class FindRecordsService {
const queryBuilderWithSelect = this.applyRestrictedFieldsToQueryBuilder(
withOrderByQueryBuilder,
repository,
objectMetadataItemWithFieldsMaps,
flatObjectMetadata,
flatFieldMetadataMaps,
);
return queryBuilderWithSelect
@@ -197,7 +224,8 @@ export class FindRecordsService {
filter,
repository,
graphqlQueryParser,
objectMetadataItemWithFieldsMaps,
flatObjectMetadata,
flatFieldMetadataMaps,
}: {
objectName: string;
filter:
@@ -206,7 +234,8 @@ export class FindRecordsService {
| undefined;
repository: WorkspaceRepository<ObjectLiteral>;
graphqlQueryParser: GraphqlQueryParser;
objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): Promise<number> {
const countQueryBuilder = repository.createQueryBuilder(objectName);
@@ -225,7 +254,8 @@ export class FindRecordsService {
const queryBuilderWithSelect = this.applyRestrictedFieldsToQueryBuilder(
withDeletedCountQueryBuilder,
repository,
objectMetadataItemWithFieldsMaps,
flatObjectMetadata,
flatFieldMetadataMaps,
);
return queryBuilderWithSelect.getCount();
@@ -12,18 +12,16 @@ import { type UpdateRecordParams } from 'src/engine/core-modules/record-crud/typ
import { getSelectedColumnsFromRestrictedFields } from 'src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class UpdateRecordService {
private readonly logger = new Logger(UpdateRecordService.name);
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly recordInputTransformerService: RecordInputTransformerService,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
) {}
async execute(params: UpdateRecordParams): Promise<ToolOutput> {
@@ -60,20 +58,34 @@ export class UpdateRecordService {
rolePermissionConfig,
);
const { objectMetadataItemWithFieldsMaps } =
await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps(
objectName,
workspaceId,
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular,
} = repository.internalContext;
const objectId = objectIdByNameSingular[objectName];
if (!isDefined(objectId)) {
throw new RecordCrudException(
`Object ${objectName} not found`,
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: objectId,
});
const restrictedFields =
repository.objectRecordsPermissions?.[
objectMetadataItemWithFieldsMaps.id
]?.restrictedFields;
repository.objectRecordsPermissions?.[flatObjectMetadata.id]
?.restrictedFields;
const selectedColumns = getSelectedColumnsFromRestrictedFields(
restrictedFields,
objectMetadataItemWithFieldsMaps,
flatObjectMetadata,
flatFieldMetadataMaps,
);
const previousObjectRecord = await repository.findOne({
@@ -102,8 +114,8 @@ export class UpdateRecordService {
if (
!canObjectBeManagedByWorkflow({
nameSingular: objectMetadataItemWithFieldsMaps.nameSingular,
isSystem: objectMetadataItemWithFieldsMaps.isSystem,
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
@@ -129,7 +141,8 @@ export class UpdateRecordService {
const transformedObjectRecord =
await this.recordInputTransformerService.process({
recordInput: objectRecordWithFilteredFields,
objectMetadataMapItem: objectMetadataItemWithFieldsMaps,
flatObjectMetadata,
flatFieldMetadataMaps,
});
const updatedObjectRecord = {
@@ -14,19 +14,18 @@ import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { getCompositeTypeOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/get-composite-type-or-throw.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { computeUniqueIndexWhereClause } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class UpsertRecordService {
private readonly logger = new Logger(UpsertRecordService.name);
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly recordInputTransformerService: RecordInputTransformerService,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
) {}
async execute(params: UpsertRecordParams): Promise<ToolOutput> {
@@ -53,16 +52,30 @@ export class UpsertRecordService {
isDefined(objectRecord[field]),
);
const { objectMetadataItemWithFieldsMaps } =
await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps(
objectName,
workspaceId,
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular,
} = repository.internalContext;
const objectId = objectIdByNameSingular[objectName];
if (!isDefined(objectId)) {
throw new RecordCrudException(
`Object ${objectName} not found`,
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: objectId,
});
if (
!canObjectBeManagedByWorkflow({
nameSingular: objectMetadataItemWithFieldsMaps.nameSingular,
isSystem: objectMetadataItemWithFieldsMaps.isSystem,
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
@@ -88,17 +101,24 @@ export class UpsertRecordService {
const transformedObjectRecord =
await this.recordInputTransformerService.process({
recordInput: objectRecordWithFilteredFields,
objectMetadataMapItem: objectMetadataItemWithFieldsMaps,
flatObjectMetadata,
flatFieldMetadataMaps,
});
const { fieldIdByName, fieldIdByJoinColumnName } =
buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
flatObjectMetadata,
);
const uniqueFieldsToUpdate = fieldsToUpdateArray
.map(
(field) =>
objectMetadataItemWithFieldsMaps.fieldIdByName[field] ||
objectMetadataItemWithFieldsMaps.fieldIdByJoinColumnName[field],
(fieldName) =>
fieldIdByName[fieldName] || fieldIdByJoinColumnName[fieldName],
)
.map((fieldId) => objectMetadataItemWithFieldsMaps.fieldsById[fieldId])
.filter((field) => field && (field.isUnique || field.name === 'id'));
.map((fieldId) => flatFieldMetadataMaps.byId[fieldId])
.filter(isDefined)
.filter((field) => field.isUnique || field.name === 'id');
const conflictPathsUniqueFieldsToUpdate = uniqueFieldsToUpdate.flatMap(
(field) => {
@@ -140,13 +160,13 @@ export class UpsertRecordService {
.filter(isDefined);
const restrictedFields =
repository.objectRecordsPermissions?.[
objectMetadataItemWithFieldsMaps.id
]?.restrictedFields;
repository.objectRecordsPermissions?.[flatObjectMetadata.id]
?.restrictedFields;
const selectedColumns = getSelectedColumnsFromRestrictedFields(
restrictedFields,
objectMetadataItemWithFieldsMaps,
flatObjectMetadata,
flatFieldMetadataMaps,
);
const upsertResult = await repository.upsert(
@@ -0,0 +1,7 @@
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
// Object metadata type enriched with flat fields for tool schema generation
export type ObjectMetadataForToolSchema = FlatObjectMetadata & {
fields: FlatFieldMetadata[];
};
@@ -1,11 +1,11 @@
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
export const generateCreateRecordInputSchema = (
objectMetadata: ObjectMetadataEntity,
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields?: RestrictedFieldsPermissions,
) => {
const recordPropertiesSchema = generateRecordPropertiesZodSchema(
@@ -1,11 +1,11 @@
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
export const generateUpdateRecordInputSchema = (
objectMetadata: ObjectMetadataEntity,
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields?: RestrictedFieldsPermissions,
) => {
const recordPropertiesSchema = generateRecordPropertiesZodSchema(
@@ -2,11 +2,14 @@ import isEmpty from 'lodash.isempty';
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
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';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export const getSelectedColumnsFromRestrictedFields = (
restrictedFields: RestrictedFieldsPermissions | undefined,
objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps,
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
): string[] | undefined => {
if (!restrictedFields || isEmpty(restrictedFields)) {
return undefined;
@@ -15,7 +18,8 @@ export const getSelectedColumnsFromRestrictedFields = (
const selectableFields = getAllSelectableColumnNames({
restrictedFields,
objectMetadata: {
objectMetadataMapItem: objectMetadataItemWithFieldsMaps,
objectMetadataMapItem: flatObjectMetadata,
flatFieldMetadataMaps,
},
});
@@ -1,15 +1,16 @@
import { z } from 'zod';
import { FieldMetadataType } from 'twenty-shared/types';
import { z } from 'zod';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
const NullCheckEnum = z.enum(['NULL', 'NOT_NULL']);
export const generateFieldFilterZodSchema = (
field: FieldMetadataEntity,
field: FieldMetadataEntity | FlatFieldMetadata,
): z.ZodTypeAny | null => {
switch (field.type) {
case FieldMetadataType.UUID:
@@ -5,14 +5,14 @@ import {
} from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema';
import { ObjectRecordOrderBySchema } from 'src/engine/core-modules/record-crud/zod-schemas/order-by.zod-schema';
import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
export const generateFindToolInputSchema = (
objectMetadata: ObjectMetadataEntity,
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields?: RestrictedFieldsPermissions,
) => {
const filterShape: Record<string, z.ZodTypeAny> = {};
@@ -9,11 +9,11 @@ import { z } from 'zod';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
const isFieldAvailable = (field: FieldMetadataEntity, forResponse: boolean) => {
const isFieldAvailable = (field: FlatFieldMetadata, forResponse: boolean) => {
if (forResponse) {
return true;
}
@@ -28,7 +28,7 @@ const isFieldAvailable = (field: FieldMetadataEntity, forResponse: boolean) => {
}
};
const getFieldZodType = (field: FieldMetadataEntity): z.ZodTypeAny => {
const getFieldZodType = (field: FlatFieldMetadata): z.ZodTypeAny => {
switch (field.type) {
case FieldMetadataType.UUID:
return z.string().uuidv4();
@@ -73,7 +73,7 @@ const getFieldZodType = (field: FieldMetadataEntity): z.ZodTypeAny => {
};
export const generateRecordPropertiesZodSchema = (
objectMetadata: ObjectMetadataEntity,
objectMetadata: ObjectMetadataForToolSchema,
forResponse = false,
restrictedFields?: RestrictedFieldsPermissions,
): z.ZodObject<Record<string, z.ZodTypeAny>> => {
@@ -11,22 +11,34 @@ import { transformEmailsValue } from 'src/engine/core-modules/record-transformer
import { transformLinksValue } from 'src/engine/core-modules/record-transformer/utils/transform-links-value.util';
import { transformPhonesValue } from 'src/engine/core-modules/record-transformer/utils/transform-phones-value.util';
import { transformRichTextV2Value } from 'src/engine/core-modules/record-transformer/utils/transform-rich-text-v2.util';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@Injectable()
export class RecordInputTransformerService {
async process({
recordInput,
objectMetadataMapItem,
flatObjectMetadata,
flatFieldMetadataMaps,
}: {
recordInput: Partial<ObjectRecord>;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): Promise<Partial<ObjectRecord>> {
let transformedEntries = {};
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
flatObjectMetadata,
);
for (const [key, value] of Object.entries(recordInput)) {
const fieldMetadataId = objectMetadataMapItem.fieldIdByName[key];
const fieldMetadata = objectMetadataMapItem.fieldsById[fieldMetadataId];
const fieldMetadataId = fieldIdByName[key];
const fieldMetadata = fieldMetadataId
? flatFieldMetadataMaps.byId[fieldMetadataId]
: undefined;
if (!fieldMetadata) {
transformedEntries = { ...transformedEntries, [key]: value };
@@ -1,7 +1,10 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { encodeCursorData } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
import { mockObjectMetadataItemsWithFieldMaps } from 'src/engine/core-modules/__mocks__/mockObjectMetadataItemsWithFieldMaps';
import {
mockFlatFieldMetadataMaps,
mockFlatObjectMetadatas,
} from 'src/engine/core-modules/__mocks__/mockFlatObjectMetadatas';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { SearchService } from 'src/engine/core-modules/search/services/search.service';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
@@ -30,53 +33,53 @@ describe('SearchService', () => {
describe('filterObjectMetadataItems', () => {
it('should return searchable object metadata items', () => {
const objectMetadataItems = service.filterObjectMetadataItems({
objectMetadataItemWithFieldMaps: mockObjectMetadataItemsWithFieldMaps,
flatObjectMetadatas: mockFlatObjectMetadatas,
includedObjectNameSingulars: [],
excludedObjectNameSingulars: [],
});
expect(objectMetadataItems).toEqual([
mockObjectMetadataItemsWithFieldMaps[0],
mockObjectMetadataItemsWithFieldMaps[1],
mockObjectMetadataItemsWithFieldMaps[2],
mockFlatObjectMetadatas[0],
mockFlatObjectMetadatas[1],
mockFlatObjectMetadatas[2],
]);
});
it('should return searchable object metadata items without excluded ones', () => {
const objectMetadataItems = service.filterObjectMetadataItems({
objectMetadataItemWithFieldMaps: mockObjectMetadataItemsWithFieldMaps,
flatObjectMetadatas: mockFlatObjectMetadatas,
includedObjectNameSingulars: [],
excludedObjectNameSingulars: ['company'],
});
expect(objectMetadataItems).toEqual([
mockObjectMetadataItemsWithFieldMaps[0],
mockObjectMetadataItemsWithFieldMaps[2],
mockFlatObjectMetadatas[0],
mockFlatObjectMetadatas[2],
]);
});
it('should return searchable object metadata items with included ones only', () => {
const objectMetadataItems = service.filterObjectMetadataItems({
objectMetadataItemWithFieldMaps: mockObjectMetadataItemsWithFieldMaps,
flatObjectMetadatas: mockFlatObjectMetadatas,
includedObjectNameSingulars: ['company'],
excludedObjectNameSingulars: [],
});
expect(objectMetadataItems).toEqual([
mockObjectMetadataItemsWithFieldMaps[1],
]);
expect(objectMetadataItems).toEqual([mockFlatObjectMetadatas[1]]);
});
});
describe('getLabelIdentifierColumns', () => {
it('should return the two label identifier columns for a person object metadata item', () => {
const labelIdentifierColumns = service.getLabelIdentifierColumns(
mockObjectMetadataItemsWithFieldMaps[0],
mockFlatObjectMetadatas[0],
mockFlatFieldMetadataMaps,
);
expect(labelIdentifierColumns).toEqual(['nameFirstName', 'nameLastName']);
});
it('should return the label identifier column for a regular object metadata item', () => {
const labelIdentifierColumns = service.getLabelIdentifierColumns(
mockObjectMetadataItemsWithFieldMaps[1],
mockFlatObjectMetadatas[1],
mockFlatFieldMetadataMaps,
);
expect(labelIdentifierColumns).toEqual(['name']);
@@ -86,14 +89,16 @@ describe('SearchService', () => {
describe('getImageIdentifierColumn', () => {
it('should return null if the object metadata item does not have an image identifier', () => {
const imageIdentifierColumn = service.getImageIdentifierColumn(
mockObjectMetadataItemsWithFieldMaps[0],
mockFlatObjectMetadatas[0],
mockFlatFieldMetadataMaps,
);
expect(imageIdentifierColumn).toBeNull();
});
it('should return `domainNamePrimaryLinkUrl` column for a company object metadata item', () => {
const imageIdentifierColumn = service.getImageIdentifierColumn(
mockObjectMetadataItemsWithFieldMaps[1],
mockFlatObjectMetadatas[1],
mockFlatFieldMetadataMaps,
);
expect(imageIdentifierColumn).toEqual('domainNamePrimaryLinkUrl');
@@ -101,7 +106,8 @@ describe('SearchService', () => {
it('should return the image identifier column', () => {
const imageIdentifierColumn = service.getImageIdentifierColumn(
mockObjectMetadataItemsWithFieldMaps[2],
mockFlatObjectMetadatas[2],
mockFlatFieldMetadataMaps,
);
expect(imageIdentifierColumn).toEqual('imageIdentifierFieldName');
@@ -4,4 +4,5 @@ export class SearchException extends CustomException<SearchExceptionCode> {}
export enum SearchExceptionCode {
LABEL_IDENTIFIER_FIELD_NOT_FOUND = 'LABEL_IDENTIFIER_FIELD_NOT_FOUND',
OBJECT_METADATA_NOT_FOUND = 'OBJECT_METADATA_NOT_FOUND',
}
@@ -14,6 +14,7 @@ export class SearchApiExceptionFilter implements ExceptionFilter {
catch(exception: SearchException) {
switch (exception.code) {
case SearchExceptionCode.LABEL_IDENTIFIER_FIELD_NOT_FOUND:
case SearchExceptionCode.OBJECT_METADATA_NOT_FOUND:
throw exception;
default: {
assertUnreachable(exception.code);
@@ -14,9 +14,6 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { buildObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-metadata-item-with-field-maps.util';
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Resolver()
@UseFilters(SearchApiExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter)
@@ -41,48 +38,29 @@ export class SearchResolver {
after,
}: SearchArgs,
) {
const { flatObjectMetadataMaps, flatFieldMetadataMaps, flatIndexMaps } =
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: workspace.id,
flatMapsKeys: [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatIndexMaps',
],
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const { idByNameSingular } = buildObjectIdByNameMaps(
flatObjectMetadataMaps,
);
const objectMetadataMaps: ObjectMetadataMaps = {
byId: {},
idByNameSingular,
};
for (const [id, flatObj] of Object.entries(flatObjectMetadataMaps.byId)) {
if (isDefined(flatObj)) {
objectMetadataMaps.byId[id] = buildObjectMetadataItemWithFieldMaps(
flatObj,
flatFieldMetadataMaps,
flatIndexMaps,
);
}
}
const flatObjectMetadatas = Object.values(
flatObjectMetadataMaps.byId,
).filter(isDefined);
const filteredObjectMetadataItems =
this.searchService.filterObjectMetadataItems({
objectMetadataItemWithFieldMaps: Object.values(
objectMetadataMaps.byId,
).filter(isDefined),
flatObjectMetadatas,
includedObjectNameSingulars: includedObjectNameSingulars ?? [],
excludedObjectNameSingulars: excludedObjectNameSingulars ?? [],
});
const allRecordsWithObjectMetadataItems =
await this.searchService.getAllRecordsWithObjectMetadataItems({
objectMetadataItemWithFieldMaps: filteredObjectMetadataItems,
flatObjectMetadatas: filteredObjectMetadataItems,
flatFieldMetadataMaps,
searchInput,
limit,
filter,
@@ -93,6 +71,7 @@ export class SearchResolver {
return this.searchService.computeSearchObjectResults({
recordsWithObjectMetadataItems: allRecordsWithObjectMetadataItems,
flatFieldMetadataMaps,
workspaceId: workspace.id,
limit,
after,
@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import chunk from 'lodash.chunk';
import { FieldMetadataType, ObjectRecord } from 'twenty-shared/types';
import { getLogoUrlFromDomainName } from 'twenty-shared/utils';
import { getLogoUrlFromDomainName, isDefined } from 'twenty-shared/utils';
import { Brackets, type ObjectLiteral } from 'typeorm';
import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
@@ -26,9 +26,10 @@ import {
} from 'src/engine/core-modules/search/exceptions/search.exception';
import { type RecordsWithObjectMetadataItem } from 'src/engine/core-modules/search/types/records-with-object-metadata-item';
import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms';
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';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { generateObjectMetadataMaps } from 'src/engine/metadata-modules/utils/generate-object-metadata-maps.util';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
@@ -49,7 +50,8 @@ export class SearchService {
) {}
async getAllRecordsWithObjectMetadataItems({
objectMetadataItemWithFieldMaps,
flatObjectMetadatas,
flatFieldMetadataMaps,
includedObjectNameSingulars,
excludedObjectNameSingulars,
searchInput,
@@ -57,10 +59,11 @@ export class SearchService {
filter,
after,
}: {
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps[];
flatObjectMetadatas: FlatObjectMetadata[];
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
} & SearchArgs) {
const filteredObjectMetadataItems = this.filterObjectMetadataItems({
objectMetadataItemWithFieldMaps,
flatObjectMetadatas,
includedObjectNameSingulars: includedObjectNameSingulars ?? [],
excludedObjectNameSingulars: excludedObjectNameSingulars ?? [],
});
@@ -75,17 +78,18 @@ export class SearchService {
for (const objectMetadataItemChunk of filteredObjectMetadataItemsChunks) {
const recordsWithObjectMetadataItems = await Promise.all(
objectMetadataItemChunk.map(async (objectMetadataItem) => {
objectMetadataItemChunk.map(async (flatObjectMetadata) => {
const repository =
await this.twentyORMManager.getRepository<ObjectRecord>(
objectMetadataItem.nameSingular,
flatObjectMetadata.nameSingular,
);
return {
objectMetadataItem,
objectMetadataItem: flatObjectMetadata,
records: await this.buildSearchQueryAndGetRecords({
entityManager: repository,
objectMetadataItem,
flatObjectMetadata,
flatFieldMetadataMaps,
searchTerms: formatSearchTerms(searchInput, 'and'),
searchTermsOr: formatSearchTerms(searchInput, 'or'),
limit: limit as number,
@@ -103,34 +107,33 @@ export class SearchService {
}
filterObjectMetadataItems({
objectMetadataItemWithFieldMaps,
flatObjectMetadatas,
includedObjectNameSingulars,
excludedObjectNameSingulars,
}: {
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps[];
flatObjectMetadatas: FlatObjectMetadata[];
includedObjectNameSingulars: string[];
excludedObjectNameSingulars: string[];
}) {
return objectMetadataItemWithFieldMaps.filter(
({ nameSingular, isSearchable }) => {
if (!isSearchable) {
return false;
}
if (excludedObjectNameSingulars.includes(nameSingular)) {
return false;
}
if (includedObjectNameSingulars.length > 0) {
return includedObjectNameSingulars.includes(nameSingular);
}
return flatObjectMetadatas.filter(({ nameSingular, isSearchable }) => {
if (!isSearchable) {
return false;
}
if (excludedObjectNameSingulars.includes(nameSingular)) {
return false;
}
if (includedObjectNameSingulars.length > 0) {
return includedObjectNameSingulars.includes(nameSingular);
}
return true;
},
);
return true;
});
}
async buildSearchQueryAndGetRecords<Entity extends ObjectLiteral>({
entityManager,
objectMetadataItem,
flatObjectMetadata,
flatFieldMetadataMaps,
searchTerms,
searchTermsOr,
limit,
@@ -138,7 +141,8 @@ export class SearchService {
after,
}: {
entityManager: WorkspaceRepository<Entity>;
objectMetadataItem: ObjectMetadataItemWithFieldMaps;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
searchTerms: string;
searchTermsOr: string;
limit: number;
@@ -147,30 +151,33 @@ export class SearchService {
}) {
const queryBuilder = entityManager.createQueryBuilder();
const { flatObjectMetadataMaps } = entityManager.internalContext;
const queryParser = new GraphqlQueryParser(
objectMetadataItem,
generateObjectMetadataMaps([
{
...objectMetadataItem,
fields: Object.values(objectMetadataItem.fieldsById),
},
]),
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
queryParser.applyFilterToBuilder(
queryBuilder,
objectMetadataItem.nameSingular,
flatObjectMetadata.nameSingular,
filter,
);
queryParser.applyDeletedAtToBuilder(queryBuilder, filter);
const imageIdentifierField =
this.getImageIdentifierColumn(objectMetadataItem);
const imageIdentifierField = this.getImageIdentifierColumn(
flatObjectMetadata,
flatFieldMetadataMaps,
);
const fieldsToSelect = [
'id',
...this.getLabelIdentifierColumns(objectMetadataItem),
...this.getLabelIdentifierColumns(
flatObjectMetadata,
flatFieldMetadataMaps,
),
...(imageIdentifierField ? [imageIdentifierField] : []),
].map((field) => `"${field}"`);
@@ -180,7 +187,7 @@ export class SearchService {
const cursorWhereCondition = this.computeCursorWhereCondition({
after,
objectMetadataNameSingular: objectMetadataItem.nameSingular,
objectMetadataNameSingular: flatObjectMetadata.nameSingular,
tsRankExpr,
tsRankCDExpr,
});
@@ -273,9 +280,10 @@ export class SearchService {
}
getLabelIdentifierColumns(
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
) {
if (!objectMetadataItem.labelIdentifierFieldMetadataId) {
if (!flatObjectMetadata.labelIdentifierFieldMetadataId) {
throw new SearchException(
'Label identifier field not found',
SearchExceptionCode.LABEL_IDENTIFIER_FIELD_NOT_FOUND,
@@ -283,10 +291,17 @@ export class SearchService {
}
const labelIdentifierField =
objectMetadataItem.fieldsById[
objectMetadataItem.labelIdentifierFieldMetadataId
flatFieldMetadataMaps.byId[
flatObjectMetadata.labelIdentifierFieldMetadataId
];
if (!isDefined(labelIdentifierField)) {
throw new SearchException(
'Label identifier field not found',
SearchExceptionCode.LABEL_IDENTIFIER_FIELD_NOT_FOUND,
);
}
if (labelIdentifierField.type === FieldMetadataType.FULL_NAME) {
return [
`${labelIdentifierField.name}FirstName`,
@@ -294,37 +309,44 @@ export class SearchService {
];
}
return [
objectMetadataItem.fieldsById[
objectMetadataItem.labelIdentifierFieldMetadataId
].name,
];
return [labelIdentifierField.name];
}
getLabelIdentifierValue(
record: ObjectRecord,
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
): string {
const labelIdentifierFields =
this.getLabelIdentifierColumns(objectMetadataItem);
const labelIdentifierFields = this.getLabelIdentifierColumns(
flatObjectMetadata,
flatFieldMetadataMaps,
);
return labelIdentifierFields.map((field) => record[field]).join(' ');
}
getImageIdentifierColumn(
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
) {
if (objectMetadataItem.nameSingular === 'company') {
if (flatObjectMetadata.nameSingular === 'company') {
return 'domainNamePrimaryLinkUrl';
}
if (!objectMetadataItem.imageIdentifierFieldMetadataId) {
if (!flatObjectMetadata.imageIdentifierFieldMetadataId) {
return null;
}
return objectMetadataItem.fieldsById[
objectMetadataItem.imageIdentifierFieldMetadataId
].name;
const imageIdentifierField =
flatFieldMetadataMaps.byId[
flatObjectMetadata.imageIdentifierFieldMetadataId
];
if (!isDefined(imageIdentifierField)) {
return null;
}
return imageIdentifierField.name;
}
private getImageUrlWithToken(avatarUrl: string, workspaceId: string): string {
@@ -336,13 +358,16 @@ export class SearchService {
getImageIdentifierValue(
record: ObjectRecord,
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
workspaceId: string,
): string {
const imageIdentifierField =
this.getImageIdentifierColumn(objectMetadataItem);
const imageIdentifierField = this.getImageIdentifierColumn(
flatObjectMetadata,
flatFieldMetadataMaps,
);
if (objectMetadataItem.nameSingular === 'company') {
if (flatObjectMetadata.nameSingular === 'company') {
return getLogoUrlFromDomainName(record.domainNamePrimaryLinkUrl) || '';
}
@@ -391,11 +416,13 @@ export class SearchService {
computeSearchObjectResults({
recordsWithObjectMetadataItems,
flatFieldMetadataMaps,
workspaceId,
limit,
after,
}: {
recordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[];
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
workspaceId: string;
limit: number;
after?: string;
@@ -406,10 +433,15 @@ export class SearchService {
return {
recordId: record.id,
objectNameSingular: objectMetadataItem.nameSingular,
label: this.getLabelIdentifierValue(record, objectMetadataItem),
label: this.getLabelIdentifierValue(
record,
objectMetadataItem,
flatFieldMetadataMaps,
),
imageUrl: this.getImageIdentifierValue(
record,
objectMetadataItem,
flatFieldMetadataMaps,
workspaceId,
),
tsRankCD: record.tsRankCD,
@@ -1,8 +1,8 @@
import { type ObjectRecord } from 'twenty-shared/types';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export type RecordsWithObjectMetadataItem = {
objectMetadataItem: ObjectMetadataItemWithFieldMaps;
objectMetadataItem: FlatObjectMetadata;
records: ObjectRecord[];
};
@@ -2,18 +2,31 @@ import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/typ
import type { ObjectRecordEvent } from 'src/engine/core-modules/event-emitter/types/object-record-event.event';
import type { WebhookEntity } from 'src/engine/core-modules/webhook/webhook.entity';
import { transformEventBatchToWebhookEvents } from 'src/engine/core-modules/webhook/utils/transform-event-batch-to-webhook-events';
import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
const mockObjectMetadata = getMockObjectMetadataEntity({
const mockObjectMetadata: FlatObjectMetadata = {
id: 'id',
nameSingular: 'nameSingular',
namePlural: 'namePlural',
workspaceId: 'workspaceId',
});
labelSingular: 'Label Singular',
labelPlural: 'Label Plural',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
createdAt: new Date(),
updatedAt: new Date(),
universalIdentifier: 'id',
fieldMetadataIds: [],
indexMetadataIds: [],
viewIds: [],
applicationId: null,
} as unknown as FlatObjectMetadata;
describe('transformEventBatchToWebhookEvents', () => {
it('should transform properly', () => {
const workspaceEventBatch = {
const workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent> = {
workspaceId: 'workspaceId',
objectMetadata: mockObjectMetadata,
name: 'objectNameSingular.created',
@@ -48,7 +61,7 @@ describe('transformEventBatchToWebhookEvents', () => {
},
},
],
} as WorkspaceEventBatch<ObjectRecordEvent>;
};
const webhooks = [
{
@@ -174,7 +187,7 @@ describe('transformEventBatchToWebhookEvents', () => {
});
it('should sanitize records properly', () => {
const workspaceEventBatch = {
const workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent> = {
workspaceId: 'workspaceId',
objectMetadata: mockObjectMetadata,
name: 'webhook.created',
@@ -190,7 +203,7 @@ describe('transformEventBatchToWebhookEvents', () => {
},
},
],
} as WorkspaceEventBatch<ObjectRecordEvent>;
};
const webhooks = [
{