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:
@ -0,0 +1,6 @@
|
||||
export interface SchemaBuilderContext {
|
||||
entityName: string;
|
||||
tableName: string;
|
||||
workspaceId: string;
|
||||
fieldAliases: Record<string, string>;
|
||||
}
|
||||
13
server/src/tenant/schema-builder/schema-builder.module.ts
Normal file
13
server/src/tenant/schema-builder/schema-builder.module.ts
Normal 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 {}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
226
server/src/tenant/schema-builder/schema-builder.service.ts
Normal file
226
server/src/tenant/schema-builder/schema-builder.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
@ -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),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
@ -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) },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user