feat: refactor custom object (#1887)

* chore: drop old universal entity

* feat: wip refactor graphql generation custom object

* feat: refactor custom object resolvers

fix: tests

fix: import

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2023-10-10 10:50:54 +02:00
committed by GitHub
parent 18c8f26f38
commit 017a0b1563
33 changed files with 588 additions and 770 deletions

View File

@ -0,0 +1,6 @@
export interface SchemaBuilderContext {
entityName: string;
tableName: string;
workspaceId: string;
fieldAliases: Record<string, string>;
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { EntityResolverModule } from 'src/tenant/entity-resolver/entity-resolver.module';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { SchemaBuilderService } from './schema-builder.service';
@Module({
imports: [EntityResolverModule],
providers: [SchemaBuilderService, JwtAuthGuard],
exports: [SchemaBuilderService],
})
export class SchemaBuilderModule {}

View File

@ -0,0 +1,27 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EntityResolverService } from 'src/tenant/entity-resolver/entity-resolver.service';
import { SchemaBuilderService } from './schema-builder.service';
describe('SchemaBuilderService', () => {
let service: SchemaBuilderService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SchemaBuilderService,
{
provide: EntityResolverService,
useValue: {},
},
],
}).compile();
service = module.get<SchemaBuilderService>(SchemaBuilderService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,226 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import {
GraphQLFieldConfigMap,
GraphQLID,
GraphQLInputObjectType,
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
GraphQLSchema,
} from 'graphql';
import { EntityResolverService } from 'src/tenant/entity-resolver/entity-resolver.service';
import { pascalCase } from 'src/utils/pascal-case';
import { ObjectMetadata } from 'src/metadata/object-metadata/object-metadata.entity';
import { generateEdgeType } from './utils/generate-edge-type.util';
import { generateConnectionType } from './utils/generate-connection-type.util';
import { generateObjectType } from './utils/generate-object-type.util';
import { generateCreateInputType } from './utils/generate-create-input-type.util';
import { generateUpdateInputType } from './utils/generate-update-input-type.util';
import { SchemaBuilderContext } from './interfaces/schema-builder-context.interface';
@Injectable()
export class SchemaBuilderService {
workspaceId: string;
constructor(private readonly entityResolverService: EntityResolverService) {}
private generateQueryFieldForEntity(
entityName: string,
tableName: string,
ObjectType: GraphQLObjectType,
objectDefinition: ObjectMetadata,
) {
const fieldAliases =
objectDefinition?.fields.reduce(
(acc, field) => ({
...acc,
[field.displayName]: field.targetColumnName,
}),
{},
) || {};
const schemaBuilderContext: SchemaBuilderContext = {
entityName,
tableName,
workspaceId: this.workspaceId,
fieldAliases,
};
const EdgeType = generateEdgeType(ObjectType);
const ConnectionType = generateConnectionType(EdgeType);
return {
[`findMany${pascalCase(entityName)}`]: {
type: ConnectionType,
resolve: async (root, args, context, info) => {
return this.entityResolverService.findMany(
schemaBuilderContext,
info,
);
},
},
[`findOne${pascalCase(entityName)}`]: {
type: ObjectType,
args: {
id: { type: new GraphQLNonNull(GraphQLID) },
},
resolve: (root, args, context, info) => {
return this.entityResolverService.findOne(
args,
schemaBuilderContext,
info,
);
},
},
} as GraphQLFieldConfigMap<any, any>;
}
private generateMutationFieldForEntity(
entityName: string,
tableName: string,
ObjectType: GraphQLObjectType,
CreateInputType: GraphQLInputObjectType,
UpdateInputType: GraphQLInputObjectType,
objectDefinition: ObjectMetadata,
) {
const fieldAliases =
objectDefinition?.fields.reduce(
(acc, field) => ({
...acc,
[field.displayName]: field.targetColumnName,
}),
{},
) || {};
const schemaBuilderContext: SchemaBuilderContext = {
entityName,
tableName,
workspaceId: this.workspaceId,
fieldAliases,
};
return {
[`createOne${pascalCase(entityName)}`]: {
type: new GraphQLNonNull(ObjectType),
args: {
data: { type: new GraphQLNonNull(CreateInputType) },
},
resolve: (root, args, context, info) => {
return this.entityResolverService.createOne(
args,
schemaBuilderContext,
info,
);
},
},
[`createMany${pascalCase(entityName)}`]: {
type: new GraphQLList(ObjectType),
args: {
data: {
type: new GraphQLNonNull(
new GraphQLList(new GraphQLNonNull(CreateInputType)),
),
},
},
resolve: (root, args, context, info) => {
return this.entityResolverService.createMany(
args,
schemaBuilderContext,
info,
);
},
},
[`updateOne${pascalCase(entityName)}`]: {
type: new GraphQLNonNull(ObjectType),
args: {
id: { type: new GraphQLNonNull(GraphQLID) },
data: { type: new GraphQLNonNull(UpdateInputType) },
},
resolve: (root, args, context, info) => {
return this.entityResolverService.updateOne(
args,
schemaBuilderContext,
info,
);
},
},
} as GraphQLFieldConfigMap<any, any>;
}
private generateQueryAndMutationTypes(objectMetadata: ObjectMetadata[]): {
query: GraphQLObjectType;
mutation: GraphQLObjectType;
} {
const queryFields: any = {};
const mutationFields: any = {};
for (const objectDefinition of objectMetadata) {
const tableName = objectDefinition?.targetTableName ?? '';
const ObjectType = generateObjectType(
objectDefinition.displayName,
objectDefinition.fields,
);
const CreateInputType = generateCreateInputType(
objectDefinition.displayName,
objectDefinition.fields,
);
const UpdateInputType = generateUpdateInputType(
objectDefinition.displayName,
objectDefinition.fields,
);
if (!objectDefinition) {
throw new InternalServerErrorException('Object definition not found');
}
Object.assign(
queryFields,
this.generateQueryFieldForEntity(
objectDefinition.displayName,
tableName,
ObjectType,
objectDefinition,
),
);
Object.assign(
mutationFields,
this.generateMutationFieldForEntity(
objectDefinition.displayName,
tableName,
ObjectType,
CreateInputType,
UpdateInputType,
objectDefinition,
),
);
}
return {
query: new GraphQLObjectType({
name: 'Query',
fields: queryFields,
}),
mutation: new GraphQLObjectType({
name: 'Mutation',
fields: mutationFields,
}),
};
}
async generateSchema(
workspaceId: string,
objectMetadata: ObjectMetadata[],
): Promise<GraphQLSchema> {
this.workspaceId = workspaceId;
const { query, mutation } =
this.generateQueryAndMutationTypes(objectMetadata);
return new GraphQLSchema({
query,
mutation,
});
}
}

View File

@ -0,0 +1,24 @@
import { GraphQLList, GraphQLNonNull, GraphQLObjectType } from 'graphql';
import { PageInfoType } from './page-into-type.util';
/**
* Generate a GraphQL connection type based on the EdgeType.
* @param EdgeType Edge type to be used in the connection.
* @returns GraphQL connection type.
*/
export const generateConnectionType = <T extends GraphQLObjectType>(
EdgeType: T,
): GraphQLObjectType<any, any> => {
return new GraphQLObjectType({
name: `${EdgeType.name.slice(0, -4)}Connection`, // Removing 'Edge' from the name
fields: {
edges: {
type: new GraphQLList(EdgeType),
},
pageInfo: {
type: new GraphQLNonNull(PageInfoType),
},
},
});
};

View File

@ -0,0 +1,33 @@
import { GraphQLInputObjectType, GraphQLNonNull } from 'graphql';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { pascalCase } from 'src/utils/pascal-case';
import { mapColumnTypeToGraphQLType } from './map-column-type-to-graphql-type.util';
/**
* Generate a GraphQL create input type based on the name and columns.
* @param name Name for the GraphQL input.
* @param columns Array of FieldMetadata columns.
* @returns GraphQLInputObjectType
*/
export const generateCreateInputType = (
name: string,
columns: FieldMetadata[],
): GraphQLInputObjectType => {
const fields: Record<string, any> = {};
columns.forEach((column) => {
const graphqlType = mapColumnTypeToGraphQLType(column);
fields[column.displayName] = {
type: !column.isNullable ? new GraphQLNonNull(graphqlType) : graphqlType,
description: column.targetColumnName,
};
});
return new GraphQLInputObjectType({
name: `${pascalCase(name)}CreateInput`,
fields,
});
};

View File

@ -0,0 +1,22 @@
import { GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql';
/**
* Generate a GraphQL edge type based on the ObjectType.
* @param ObjectType Object type to be used in the Edge.
* @returns GraphQL edge type.
*/
export const generateEdgeType = <T extends GraphQLObjectType>(
ObjectType: T,
): GraphQLObjectType<any, any> => {
return new GraphQLObjectType({
name: `${ObjectType.name}Edge`,
fields: {
node: {
type: ObjectType,
},
cursor: {
type: new GraphQLNonNull(GraphQLString),
},
},
});
};

View File

@ -0,0 +1,46 @@
import {
GraphQLID,
GraphQLNonNull,
GraphQLObjectType,
GraphQLString,
} from 'graphql';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { pascalCase } from 'src/utils/pascal-case';
import { mapColumnTypeToGraphQLType } from './map-column-type-to-graphql-type.util';
const defaultFields = {
id: { type: new GraphQLNonNull(GraphQLID) },
createdAt: { type: new GraphQLNonNull(GraphQLString) },
updatedAt: { type: new GraphQLNonNull(GraphQLString) },
};
/**
* Generate a GraphQL object type based on the name and columns.
* @param name Name for the GraphQL object.
* @param columns Array of FieldMetadata columns.
* @returns GraphQLObjectType
*/
export const generateObjectType = <TSource = any, TContext = any>(
name: string,
columns: FieldMetadata[],
): GraphQLObjectType<TSource, TContext> => {
const fields = {
...defaultFields,
};
columns.forEach((column) => {
const graphqlType = mapColumnTypeToGraphQLType(column);
fields[column.displayName] = {
type: !column.isNullable ? new GraphQLNonNull(graphqlType) : graphqlType,
description: column.targetColumnName,
};
});
return new GraphQLObjectType({
name: pascalCase(name),
fields,
});
};

View File

@ -0,0 +1,33 @@
import { GraphQLInputObjectType } from 'graphql';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { pascalCase } from 'src/utils/pascal-case';
import { mapColumnTypeToGraphQLType } from './map-column-type-to-graphql-type.util';
/**
* Generate a GraphQL update input type based on the name and columns.
* @param name Name for the GraphQL input.
* @param columns Array of FieldMetadata columns.
* @returns GraphQLInputObjectType
*/
export const generateUpdateInputType = (
name: string,
columns: FieldMetadata[],
): GraphQLInputObjectType => {
const fields: Record<string, any> = {};
columns.forEach((column) => {
const graphqlType = mapColumnTypeToGraphQLType(column);
// No GraphQLNonNull wrapping here, so all fields are optional
fields[column.displayName] = {
type: graphqlType,
description: column.targetColumnName,
};
});
return new GraphQLInputObjectType({
name: `${pascalCase(name)}UpdateInput`,
fields,
});
};

View File

@ -0,0 +1,45 @@
import {
GraphQLBoolean,
GraphQLEnumType,
GraphQLID,
GraphQLInt,
GraphQLString,
} from 'graphql';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { pascalCase } from 'src/utils/pascal-case';
/**
* Map the column type from field-metadata to its corresponding GraphQL type.
* @param columnType Type of the column in the database.
*/
export const mapColumnTypeToGraphQLType = (column: FieldMetadata) => {
switch (column.type) {
case 'uuid':
return GraphQLID;
case 'text':
case 'url':
case 'date':
return GraphQLString;
case 'boolean':
return GraphQLBoolean;
case 'number':
return GraphQLInt;
case 'enum': {
if (column.enums && column.enums.length > 0) {
const enumName = `${pascalCase(column.objectId)}${pascalCase(
column.displayName,
)}Enum`;
return new GraphQLEnumType({
name: enumName,
values: Object.fromEntries(
column.enums.map((value) => [value, { value }]),
),
});
}
}
default:
return GraphQLString;
}
};

View File

@ -0,0 +1,15 @@
import { ConnectionCursorScalar } from '@ptc-org/nestjs-query-graphql';
import { GraphQLBoolean, GraphQLNonNull, GraphQLObjectType } from 'graphql';
/**
* GraphQL PageInfo type.
*/
export const PageInfoType = new GraphQLObjectType({
name: 'PageInfo',
fields: {
startCursor: { type: ConnectionCursorScalar },
endCursor: { type: ConnectionCursorScalar },
hasNextPage: { type: new GraphQLNonNull(GraphQLBoolean) },
hasPreviousPage: { type: new GraphQLNonNull(GraphQLBoolean) },
},
});