Setup relations for remote objects (#5149)

New strategy:
- add settings field on FieldMetadata. Contains a boolean isIdField and
for numbers, a precision
- if idField, the graphql scalar returned will be a GraphQL id. This
will allow the app to work even for ids that are not uuid
- remove globals dateScalar and numberScalar modes. These were not used
- set limit as Integer
- check manually in query runner mutations that we send a valid id

Todo left:
- remove WorkspaceBuildSchemaOptions since this is not used anymore.
Will do in another PR

---------

Co-authored-by: Thomas Trompette <thomast@twenty.com>
Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
Thomas Trompette
2024-04-26 14:37:34 +02:00
committed by GitHub
parent dc576d0818
commit 224c8d361b
71 changed files with 616 additions and 223 deletions

View File

@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { ResolverArgsType } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@ -14,6 +15,7 @@ describe('QueryRunnerArgsFactory', () => {
const options = {
fieldMetadataCollection: [
{ name: 'position', type: FieldMetadataType.POSITION },
{ name: 'testNumber', type: FieldMetadataType.NUMBER },
] as FieldMetadataInterface[],
objectMetadataItem: { isCustom: true, nameSingular: 'test' },
} as WorkspaceQueryRunnerOptions;
@ -45,18 +47,92 @@ describe('QueryRunnerArgsFactory', () => {
const args = {
data: [],
};
const result = await factory.create(args, options);
const result = await factory.create(
args,
options,
ResolverArgsType.CreateMany,
);
expect(result).toEqual(args);
});
it('should override args when of type array', async () => {
const args = { data: [{ id: 1 }, { position: 'last' }] };
it('createMany type should override data position and number', async () => {
const args = {
id: 'uuid',
data: [{ position: 'last', testNumber: '1' }],
};
const result = await factory.create(args, options);
const result = await factory.create(
args,
options,
ResolverArgsType.CreateMany,
);
expect(result).toEqual({
data: [{ id: 1 }, { position: 2 }],
id: 'uuid',
data: [{ position: 2, testNumber: 1 }],
});
});
it('findMany type should override data position and number', async () => {
const args = {
id: 'uuid',
filter: { testNumber: { eq: '1' }, otherField: { eq: 'test' } },
};
const result = await factory.create(
args,
options,
ResolverArgsType.FindMany,
);
expect(result).toEqual({
id: 'uuid',
filter: { testNumber: { eq: 1 }, otherField: { eq: 'test' } },
});
});
it('findOne type should override number in filter', async () => {
const args = {
id: 'uuid',
filter: { testNumber: { eq: '1' }, otherField: { eq: 'test' } },
};
const result = await factory.create(
args,
options,
ResolverArgsType.FindOne,
);
expect(result).toEqual({
id: 'uuid',
filter: { testNumber: { eq: 1 }, otherField: { eq: 'test' } },
});
});
it('findDuplicates type should override number in data and id', async () => {
const optionsDuplicate = {
fieldMetadataCollection: [
{ name: 'id', type: FieldMetadataType.NUMBER },
{ name: 'testNumber', type: FieldMetadataType.NUMBER },
] as FieldMetadataInterface[],
objectMetadataItem: { isCustom: true, nameSingular: 'test' },
} as WorkspaceQueryRunnerOptions;
const args = {
id: '123',
data: { testNumber: '1', otherField: 'test' },
};
const result = await factory.create(
args,
optionsDuplicate,
ResolverArgsType.FindDuplicates,
);
expect(result).toEqual({
id: 123,
data: { testNumber: 1, otherField: 'test' },
});
});
});

View File

@ -2,6 +2,15 @@ import { Injectable } from '@nestjs/common';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import {
CreateManyResolverArgs,
FindDuplicatesResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
ResolverArgs,
ResolverArgsType,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { RecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@ -12,8 +21,9 @@ export class QueryRunnerArgsFactory {
constructor(private readonly recordPositionFactory: RecordPositionFactory) {}
async create(
args: Record<string, any>,
args: ResolverArgs,
options: WorkspaceQueryRunnerOptions,
resolverArgsType: ResolverArgsType,
) {
const fieldMetadataCollection = options.fieldMetadataCollection;
@ -24,21 +34,62 @@ export class QueryRunnerArgsFactory {
]),
);
return {
data: await Promise.all(
args.data.map((arg) =>
this.overrideArgByFieldMetadata(arg, options, fieldMetadataMap),
),
),
};
switch (resolverArgsType) {
case ResolverArgsType.CreateMany:
return {
...args,
data: await Promise.all(
(args as CreateManyResolverArgs).data.map((arg) =>
this.overrideDataByFieldMetadata(arg, options, fieldMetadataMap),
),
),
} satisfies CreateManyResolverArgs;
case ResolverArgsType.FindOne:
return {
...args,
filter: await this.overrideFilterByFieldMetadata(
(args as FindOneResolverArgs).filter,
fieldMetadataMap,
),
};
case ResolverArgsType.FindMany:
return {
...args,
filter: await this.overrideFilterByFieldMetadata(
(args as FindManyResolverArgs).filter,
fieldMetadataMap,
),
};
case ResolverArgsType.FindDuplicates:
return {
...args,
id: await this.overrideValueByFieldMetadata(
'id',
(args as FindDuplicatesResolverArgs).id,
fieldMetadataMap,
),
data: await this.overrideDataByFieldMetadata(
(args as FindDuplicatesResolverArgs).data,
options,
fieldMetadataMap,
),
};
default:
return args;
}
}
private async overrideArgByFieldMetadata(
arg: Record<string, any>,
private async overrideDataByFieldMetadata(
data: Record<string, any> | undefined,
options: WorkspaceQueryRunnerOptions,
fieldMetadataMap: Map<string, FieldMetadataInterface>,
) {
const createArgPromiseByArgKey = Object.entries(arg).map(
if (!data) {
return;
}
const createArgPromiseByArgKey = Object.entries(data).map(
async ([key, value]) => {
const fieldMetadata = fieldMetadataMap.get(key);
@ -59,6 +110,8 @@ export class QueryRunnerArgsFactory {
options.workspaceId,
),
];
case FieldMetadataType.NUMBER:
return [key, await Promise.resolve(Number(value))];
default:
return [key, await Promise.resolve(value)];
}
@ -69,4 +122,57 @@ export class QueryRunnerArgsFactory {
return Object.fromEntries(newArgEntries);
}
private overrideFilterByFieldMetadata(
filter: RecordFilter | undefined,
fieldMetadataMap: Map<string, FieldMetadataInterface>,
) {
if (!filter) {
return;
}
const createArgPromiseByArgKey = Object.entries(filter).map(
([key, value]) => {
const fieldMetadata = fieldMetadataMap.get(key);
if (!fieldMetadata) {
return [key, value];
}
const createFilterByKey = Object.entries(value).map(
([filterKey, filterValue]) => {
switch (fieldMetadata.type) {
case FieldMetadataType.NUMBER:
return [filterKey, Number(filterValue)];
default:
return [filterKey, filterValue];
}
},
);
return [key, Object.fromEntries(createFilterByKey)];
},
);
return Object.fromEntries(createArgPromiseByArgKey);
}
private async overrideValueByFieldMetadata(
key: string,
value: any,
fieldMetadataMap: Map<string, FieldMetadataInterface>,
) {
const fieldMetadata = fieldMetadataMap.get(key);
if (!fieldMetadata) {
return value;
}
switch (fieldMetadata.type) {
case FieldMetadataType.NUMBER:
return Number(value);
default:
return value;
}
}
}

View File

@ -0,0 +1,12 @@
import { BadRequestException } from '@nestjs/common';
export const assertIsValidUuid = (value: string) => {
const isValid =
/^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(
value,
);
if (!isValid) {
throw new BadRequestException(`Value "${value}" is not a valid UUID`);
}
};

View File

@ -22,6 +22,7 @@ import {
FindDuplicatesResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
ResolverArgsType,
UpdateManyResolverArgs,
UpdateOneResolverArgs,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
@ -48,6 +49,7 @@ import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-r
import { QueryResultGettersFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters.factory';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assertIsValidUuid.util';
import { WorkspaceQueryRunnerOptions } from './interfaces/query-runner-option.interface';
import {
@ -83,9 +85,15 @@ export class WorkspaceQueryRunnerService {
const { workspaceId, userId, objectMetadataItem } = options;
const start = performance.now();
const query = await this.workspaceQueryBuilderFactory.findMany(
const computedArgs = (await this.queryRunnerArgsFactory.create(
args,
options,
ResolverArgsType.FindMany,
)) as FindManyResolverArgs<Filter, OrderBy>;
const query = await this.workspaceQueryBuilderFactory.findMany(
computedArgs,
options,
);
await this.workspacePreQueryHookService.executePreHooks(
@ -123,9 +131,16 @@ export class WorkspaceQueryRunnerService {
throw new BadRequestException('Missing filter argument');
}
const { workspaceId, userId, objectMetadataItem } = options;
const query = await this.workspaceQueryBuilderFactory.findOne(
const computedArgs = (await this.queryRunnerArgsFactory.create(
args,
options,
ResolverArgsType.FindOne,
)) as FindOneResolverArgs<Filter>;
const query = await this.workspaceQueryBuilderFactory.findOne(
computedArgs,
options,
);
await this.workspacePreQueryHookService.executePreHooks(
@ -164,12 +179,18 @@ export class WorkspaceQueryRunnerService {
const { workspaceId, userId, objectMetadataItem } = options;
const computedArgs = (await this.queryRunnerArgsFactory.create(
args,
options,
ResolverArgsType.FindDuplicates,
)) as FindDuplicatesResolverArgs<TRecord>;
let existingRecord: Record<string, unknown> | undefined;
if (args.id) {
if (computedArgs.id) {
const existingRecordQuery =
this.workspaceQueryBuilderFactory.findDuplicatesExistingRecord(
args.id,
computedArgs.id,
options,
);
@ -192,7 +213,7 @@ export class WorkspaceQueryRunnerService {
}
const query = await this.workspaceQueryBuilderFactory.findDuplicates(
args,
computedArgs,
options,
existingRecord,
);
@ -202,7 +223,7 @@ export class WorkspaceQueryRunnerService {
workspaceId,
objectMetadataItem.nameSingular,
'findDuplicates',
args,
computedArgs,
);
const result = await this.execute(query, workspaceId);
@ -222,10 +243,17 @@ export class WorkspaceQueryRunnerService {
assertMutationNotOnRemoteObject(objectMetadataItem);
const computedArgs = await this.queryRunnerArgsFactory.create(
args.data.forEach((record) => {
if (record.id) {
assertIsValidUuid(record.id);
}
});
const computedArgs = (await this.queryRunnerArgsFactory.create(
args,
options,
);
ResolverArgsType.CreateMany,
)) as CreateManyResolverArgs<Record>;
await this.workspacePreQueryHookService.executePreHooks(
userId,
@ -288,6 +316,7 @@ export class WorkspaceQueryRunnerService {
const { workspaceId, userId, objectMetadataItem } = options;
assertMutationNotOnRemoteObject(objectMetadataItem);
assertIsValidUuid(args.id);
const existingRecord = await this.findOne(
{ filter: { id: { eq: args.id } } } as FindOneResolverArgs,
@ -337,6 +366,7 @@ export class WorkspaceQueryRunnerService {
const { workspaceId, objectMetadataItem } = options;
assertMutationNotOnRemoteObject(objectMetadataItem);
assertIsValidUuid(args.data.id);
const maximumRecordAffected = this.environmentService.get(
'MUTATION_MAXIMUM_RECORD_AFFECTED',