Fix REST API not using metadata cache (#10521)

This commit is contained in:
Weiko
2025-02-26 17:59:35 +01:00
committed by GitHub
parent a6ea8b98b6
commit d40a5ed74f
13 changed files with 245 additions and 74 deletions

View File

@ -19,9 +19,12 @@ import { parseCoreBatchPath } from 'src/engine/api/rest/core/query-builder/utils
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { Query } from 'src/engine/api/rest/core/types/query.type';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { getObjectMetadataMapItemByNamePlural } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-plural.util';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()
export class CoreQueryBuilderFactory {
@ -38,25 +41,35 @@ export class CoreQueryBuilderFactory {
private readonly updateVariablesFactory: UpdateVariablesFactory,
private readonly getVariablesFactory: GetVariablesFactory,
private readonly findDuplicatesVariablesFactory: FindDuplicatesVariablesFactory,
private readonly objectMetadataService: ObjectMetadataService,
private readonly accessTokenService: AccessTokenService,
private readonly domainManagerService: DomainManagerService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
async getObjectMetadata(
request: Request,
parsedObject: string,
): Promise<{
objectMetadataItems: ObjectMetadataEntity[];
objectMetadataItem: ObjectMetadataEntity;
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
}> {
const { workspace } =
await this.accessTokenService.validateTokenByRequest(request);
const objectMetadataItems =
await this.objectMetadataService.findManyWithinWorkspace(workspace.id);
const currentCacheVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id);
if (!objectMetadataItems.length) {
if (currentCacheVersion === undefined) {
throw new BadRequestException('No cacheVersion');
}
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMaps(
workspace.id,
currentCacheVersion,
);
if (!objectMetadataMaps) {
throw new BadRequestException(
`No object was found for the workspace associated with this API key. You may generate a new one here ${this.domainManagerService
.buildWorkspaceURL({
@ -67,19 +80,21 @@ export class CoreQueryBuilderFactory {
);
}
const [objectMetadata] = objectMetadataItems.filter(
(object) => object.namePlural === parsedObject,
const objectMetadataItem = getObjectMetadataMapItemByNamePlural(
objectMetadataMaps,
parsedObject,
);
if (!objectMetadata) {
const [wrongObjectMetadata] = objectMetadataItems.filter(
(object) => object.nameSingular === parsedObject,
if (!objectMetadataItem) {
const wrongObjectMetadataItem = getObjectMetadataMapItemByNameSingular(
objectMetadataMaps,
parsedObject,
);
let hint = 'eg: companies';
if (wrongObjectMetadata) {
hint = `Did you mean '${wrongObjectMetadata.namePlural}'?`;
if (wrongObjectMetadataItem) {
hint = `Did you mean '${wrongObjectMetadataItem.namePlural}'?`;
}
throw new BadRequestException(
@ -88,8 +103,8 @@ export class CoreQueryBuilderFactory {
}
return {
objectMetadataItems,
objectMetadataItem: objectMetadata,
objectMetadataMaps,
objectMetadataMapItem: objectMetadataItem,
};
}
@ -101,12 +116,14 @@ export class CoreQueryBuilderFactory {
if (!id) {
throw new BadRequestException(
`delete ${objectMetadata.objectMetadataItem.nameSingular} query invalid. Id missing. eg: /rest/${objectMetadata.objectMetadataItem.namePlural}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`,
`delete ${objectMetadata.objectMetadataMapItem.nameSingular} query invalid. Id missing. eg: /rest/${objectMetadata.objectMetadataMapItem.namePlural}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`,
);
}
return {
query: this.deleteQueryFactory.create(objectMetadata.objectMetadataItem),
query: this.deleteQueryFactory.create(
objectMetadata.objectMetadataMapItem,
),
variables: this.deleteVariablesFactory.create(id),
};
}
@ -144,7 +161,7 @@ export class CoreQueryBuilderFactory {
if (!id) {
throw new BadRequestException(
`update ${objectMetadata.objectMetadataItem.nameSingular} query invalid. Id missing. eg: /rest/${objectMetadata.objectMetadataItem.namePlural}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`,
`update ${objectMetadata.objectMetadataMapItem.nameSingular} query invalid. Id missing. eg: /rest/${objectMetadata.objectMetadataMapItem.namePlural}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`,
);
}

View File

@ -2,12 +2,12 @@ import { Module } from '@nestjs/common';
import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory';
import { coreQueryBuilderFactories } from 'src/engine/api/rest/core/query-builder/factories/factories';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [ObjectMetadataModule, AuthModule, DomainManagerModule],
imports: [AuthModule, DomainManagerModule, WorkspaceCacheStorageModule],
providers: [...coreQueryBuilderFactories, CoreQueryBuilderFactory],
exports: [CoreQueryBuilderFactory],
})

View File

@ -3,25 +3,33 @@ import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared';
import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class CreateManyQueryFactory {
create(objectMetadata, depth?: number): string {
create(
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
},
depth?: number,
): string {
const objectNamePlural = capitalize(
objectMetadata.objectMetadataItem.namePlural,
objectMetadata.objectMetadataMapItem.namePlural,
);
const objectNameSingular = capitalize(
objectMetadata.objectMetadataItem.nameSingular,
objectMetadata.objectMetadataMapItem.nameSingular,
);
return `
mutation Create${objectNamePlural}($data: [${objectNameSingular}CreateInput!]) {
create${objectNamePlural}(data: $data) {
id
${objectMetadata.objectMetadataItem.fields
${objectMetadata.objectMetadataMapItem.fields
.map((field) =>
mapFieldMetadataToGraphqlQuery(
objectMetadata.objectMetadataItems,
objectMetadata.objectMetadataMaps,
field,
depth,
),

View File

@ -3,22 +3,30 @@ import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared';
import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class CreateOneQueryFactory {
create(objectMetadata, depth?: number): string {
create(
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
},
depth?: number,
): string {
const objectNameSingular = capitalize(
objectMetadata.objectMetadataItem.nameSingular,
objectMetadata.objectMetadataMapItem.nameSingular,
);
return `
mutation Create${objectNameSingular}($data: ${objectNameSingular}CreateInput!) {
create${objectNameSingular}(data: $data) {
id
${objectMetadata.objectMetadataItem.fields
${objectMetadata.objectMetadataMapItem.fields
.map((field) =>
mapFieldMetadataToGraphqlQuery(
objectMetadata.objectMetadataItems,
objectMetadata.objectMetadataMaps,
field,
depth,
),

View File

@ -2,10 +2,12 @@ import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
@Injectable()
export class DeleteQueryFactory {
create(objectMetadataItem): string {
const objectNameSingular = capitalize(objectMetadataItem.nameSingular);
create(objectMetadataMapItem: ObjectMetadataItemWithFieldMaps): string {
const objectNameSingular = capitalize(objectMetadataMapItem.nameSingular);
return `
mutation Delete${objectNameSingular}($id: ID!) {

View File

@ -3,11 +3,20 @@ import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared';
import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class FindDuplicatesQueryFactory {
create(objectMetadata, depth?: number): string {
const objectNameSingular = objectMetadata.objectMetadataItem.nameSingular;
create(
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
},
depth?: number,
): string {
const objectNameSingular =
objectMetadata.objectMetadataMapItem.nameSingular;
return `
query FindDuplicate${capitalize(
@ -22,10 +31,10 @@ export class FindDuplicatesQueryFactory {
}
edges{
node {
${objectMetadata.objectMetadataItem.fields
${objectMetadata.objectMetadataMapItem.fields
.map((field) =>
mapFieldMetadataToGraphqlQuery(
objectMetadata.objectMetadataItems,
objectMetadata.objectMetadataMaps,
field,
depth,
),

View File

@ -3,14 +3,22 @@ import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared';
import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class FindManyQueryFactory {
create(objectMetadata, depth?: number): string {
create(
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
},
depth?: number,
): string {
const objectNameSingular = capitalize(
objectMetadata.objectMetadataItem.nameSingular,
objectMetadata.objectMetadataMapItem.nameSingular,
);
const objectNamePlural = objectMetadata.objectMetadataItem.namePlural;
const objectNamePlural = objectMetadata.objectMetadataMapItem.namePlural;
return `
query FindMany${capitalize(objectNamePlural)}(
@ -32,10 +40,10 @@ export class FindManyQueryFactory {
edges {
node {
id
${objectMetadata.objectMetadataItem.fields
${objectMetadata.objectMetadataMapItem.fields
.map((field) =>
mapFieldMetadataToGraphqlQuery(
objectMetadata.objectMetadataItems,
objectMetadata.objectMetadataMaps,
field,
depth,
),

View File

@ -3,11 +3,20 @@ import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared';
import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class FindOneQueryFactory {
create(objectMetadata, depth?: number): string {
const objectNameSingular = objectMetadata.objectMetadataItem.nameSingular;
create(
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
},
depth?: number,
): string {
const objectNameSingular =
objectMetadata.objectMetadataMapItem.nameSingular;
return `
query FindOne${capitalize(objectNameSingular)}(
@ -15,10 +24,10 @@ export class FindOneQueryFactory {
) {
${objectNameSingular}(filter: $filter) {
id
${objectMetadata.objectMetadataItem.fields
${objectMetadata.objectMetadataMapItem.fields
.map((field) =>
mapFieldMetadataToGraphqlQuery(
objectMetadata.objectMetadataItems,
objectMetadata.objectMetadataMaps,
field,
depth,
),

View File

@ -3,11 +3,20 @@ import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared';
import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class UpdateQueryFactory {
create(objectMetadata, depth?: number): string {
const objectNameSingular = objectMetadata.objectMetadataItem.nameSingular;
create(
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
},
depth?: number,
): string {
const objectNameSingular =
objectMetadata.objectMetadataMapItem.nameSingular;
return `
mutation Update${capitalize(
@ -15,10 +24,10 @@ export class UpdateQueryFactory {
)}($id: ID!, $data: ${capitalize(objectNameSingular)}UpdateInput!) {
update${capitalize(objectNameSingular)}(id: $id, data: $data) {
id
${objectMetadata.objectMetadataItem.fields
${Object.values(objectMetadata.objectMetadataMapItem.fieldsById)
.map((field) =>
mapFieldMetadataToGraphqlQuery(
objectMetadata.objectMetadataItems,
objectMetadata.objectMetadataMaps,
field,
depth,
),

View File

@ -1,5 +1,7 @@
import { FieldMetadataType } from 'twenty-shared';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import {
fieldCurrencyMock,
fieldNumberMock,
@ -8,19 +10,85 @@ import {
} from 'src/engine/api/__mocks__/object-metadata-item.mock';
import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils';
import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
describe('mapFieldMetadataToGraphqlQuery', () => {
const typedFieldNumberMock: FieldMetadataInterface = {
id: 'field-number-id',
name: fieldNumberMock.name,
type: fieldNumberMock.type,
label: 'Field Number',
objectMetadataId: 'object-metadata-id',
isNullable: fieldNumberMock.isNullable,
defaultValue: fieldNumberMock.defaultValue,
};
const typedFieldTextMock: FieldMetadataInterface = {
id: 'field-text-id',
name: fieldTextMock.name,
type: fieldTextMock.type,
label: 'Field Text',
objectMetadataId: 'object-metadata-id',
isNullable: fieldTextMock.isNullable,
defaultValue: fieldTextMock.defaultValue,
};
const typedFieldCurrencyMock: FieldMetadataInterface = {
id: 'field-currency-id',
name: fieldCurrencyMock.name,
type: fieldCurrencyMock.type,
label: 'Field Currency',
objectMetadataId: 'object-metadata-id',
isNullable: fieldCurrencyMock.isNullable,
defaultValue: fieldCurrencyMock.defaultValue,
};
const fieldsById: FieldMetadataMap = {
'field-number-id': typedFieldNumberMock,
'field-text-id': typedFieldTextMock,
'field-currency-id': typedFieldCurrencyMock,
};
const fieldsByName: FieldMetadataMap = {
[typedFieldNumberMock.name]: typedFieldNumberMock,
[typedFieldTextMock.name]: typedFieldTextMock,
[typedFieldCurrencyMock.name]: typedFieldCurrencyMock,
};
const typedObjectMetadataItem: ObjectMetadataItemWithFieldMaps = {
...objectMetadataItemMock,
fieldsById,
fieldsByName,
};
const objectMetadataMapsMock: ObjectMetadataMaps = {
byId: {
[objectMetadataItemMock.id]: typedObjectMetadataItem,
},
idByNameSingular: {
[objectMetadataItemMock.nameSingular]: objectMetadataItemMock.id,
},
};
it('should map properly', () => {
expect(
mapFieldMetadataToGraphqlQuery([objectMetadataItemMock], fieldNumberMock),
mapFieldMetadataToGraphqlQuery(
objectMetadataMapsMock,
typedFieldNumberMock,
),
).toEqual('fieldNumber');
expect(
mapFieldMetadataToGraphqlQuery([objectMetadataItemMock], fieldTextMock),
mapFieldMetadataToGraphqlQuery(
objectMetadataMapsMock,
typedFieldTextMock,
),
).toEqual('fieldText');
expect(
mapFieldMetadataToGraphqlQuery(
[objectMetadataItemMock],
fieldCurrencyMock,
objectMetadataMapsMock,
typedFieldCurrencyMock,
),
).toEqual(`
fieldCurrency
@ -30,19 +98,24 @@ describe('mapFieldMetadataToGraphqlQuery', () => {
}
`);
});
describe('should handle all field metadata types', () => {
Object.values(FieldMetadataType).forEach((fieldMetadataType) => {
it(`with field type ${fieldMetadataType}`, () => {
const field = {
const field: FieldMetadataInterface = {
id: 'test-field-id',
type: fieldMetadataType,
name: 'toObjectMetadataName',
label: 'Test Field',
objectMetadataId: 'object-metadata-id',
fromRelationMetadata: {
relationType: RelationMetadataType.ONE_TO_MANY,
},
toObjectMetadataId: objectMetadataItemMock.id,
} as any,
};
expect(
mapFieldMetadataToGraphqlQuery([objectMetadataItemMock], field),
mapFieldMetadataToGraphqlQuery(objectMetadataMapsMock, field),
).toBeDefined();
});
});

View File

@ -1,13 +1,16 @@
import { FieldMetadataType } from 'twenty-shared';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
const DEFAULT_DEPTH_VALUE = 1;
// TODO: Should be properly type and based on composite type definitions
export const mapFieldMetadataToGraphqlQuery = (
objectMetadataItems,
field,
objectMetadataMaps: ObjectMetadataMaps,
field: FieldMetadataInterface,
maxDepthForRelations = DEFAULT_DEPTH_VALUE,
): string | undefined => {
if (maxDepthForRelations < 0) {
@ -41,19 +44,25 @@ export const mapFieldMetadataToGraphqlQuery = (
fieldType === FieldMetadataType.RELATION &&
field.toRelationMetadata?.relationType === RelationMetadataType.ONE_TO_MANY
) {
const relationMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id ===
(field.toRelationMetadata as any)?.fromObjectMetadataId,
);
const fromObjectMetadataId = field.toRelationMetadata?.fromObjectMetadataId;
if (!fromObjectMetadataId) {
return '';
}
const relationMetadataItem = objectMetadataMaps.byId[fromObjectMetadataId];
if (!relationMetadataItem) {
return '';
}
return `${field.name}
{
id
${(relationMetadataItem?.fields ?? [])
${Object.values(relationMetadataItem.fieldsById)
.map((field) =>
mapFieldMetadataToGraphqlQuery(
objectMetadataItems,
objectMetadataMaps,
field,
maxDepthForRelations - 1,
),
@ -66,21 +75,27 @@ export const mapFieldMetadataToGraphqlQuery = (
field.fromRelationMetadata?.relationType ===
RelationMetadataType.ONE_TO_MANY
) {
const relationMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id ===
(field.fromRelationMetadata as any)?.toObjectMetadataId,
);
const toObjectMetadataId = field.fromRelationMetadata?.toObjectMetadataId;
if (!toObjectMetadataId) {
return '';
}
const relationMetadataItem = objectMetadataMaps.byId[toObjectMetadataId];
if (!relationMetadataItem) {
return '';
}
return `${field.name}
{
edges {
node {
id
${(relationMetadataItem?.fields ?? [])
${Object.values(relationMetadataItem.fieldsById)
.map((field) =>
mapFieldMetadataToGraphqlQuery(
objectMetadataItems,
objectMetadataMaps,
field,
maxDepthForRelations - 1,
),

View File

@ -106,7 +106,7 @@ export class RestApiCoreServiceV2 {
}
const objectMetadataNameSingular =
objectMetadata.objectMetadataItem.nameSingular;
objectMetadata.objectMetadataMapItem.nameSingular;
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspace.id,

View File

@ -0,0 +1,13 @@
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
export const getObjectMetadataMapItemByNamePlural = (
objectMetadataMaps: ObjectMetadataMaps,
namePlural: string,
): ObjectMetadataItemWithFieldMaps | undefined => {
const objectMetadataItems = Object.values(objectMetadataMaps.byId);
return objectMetadataItems.find(
(objectMetadata) => objectMetadata.namePlural === namePlural,
);
};