diff --git a/server/src/tenant/entity-resolver/entity-resolver.service.ts b/server/src/tenant/entity-resolver/entity-resolver.service.ts index 2d2967012..a2f57b1a1 100644 --- a/server/src/tenant/entity-resolver/entity-resolver.service.ts +++ b/server/src/tenant/entity-resolver/entity-resolver.service.ts @@ -6,12 +6,20 @@ import { import { GraphQLResolveInfo } from 'graphql'; import graphqlFields from 'graphql-fields'; +import { v4 as uuidv4 } from 'uuid'; import { DataSourceService } from 'src/metadata/data-source/data-source.service'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; +import { pascalCase } from 'src/utils/pascal-case'; import { convertFieldsToGraphQL } from './entity-resolver.util'; +function stringify(obj: any) { + const jsonString = JSON.stringify(obj); + const jsonWithoutQuotes = jsonString.replace(/"(\w+)"\s*:/g, '$1:'); + return jsonWithoutQuotes; +} + @Injectable() export class EntityResolverService { constructor( @@ -49,7 +57,7 @@ export class EntityResolverService { const graphqlResult = await workspaceDataSource?.query(` SELECT graphql.resolve($$ { - ${entityName}Collection: ${tableName}Collection { + findAll${pascalCase(entityName)}: ${tableName}Collection { ${graphqlQuery} } } @@ -57,7 +65,7 @@ export class EntityResolverService { `); const result = - graphqlResult?.[0]?.resolve?.data?.[`${entityName}Collection`]; + graphqlResult?.[0]?.resolve?.data?.[`findAll${pascalCase(entityName)}`]; if (!result) { throw new BadRequestException('Malformed result from GraphQL query'); @@ -93,7 +101,9 @@ export class EntityResolverService { const graphqlResult = await workspaceDataSource?.query(` SELECT graphql.resolve($$ { - ${entityName}Collection: : ${tableName}Collection(filter: { id: { eq: "${args.id}" } }) { + findOne${pascalCase( + entityName, + )}: ${tableName}Collection(filter: { id: { eq: "${args.id}" } }) { ${graphqlQuery} } } @@ -101,10 +111,168 @@ export class EntityResolverService { `); const result = - graphqlResult?.[0]?.resolve?.data?.[`${entityName}Collection`]; + graphqlResult?.[0]?.resolve?.data?.[`findOne${pascalCase(entityName)}`]; if (!result) { - return null; + throw new BadRequestException('Malformed result from GraphQL query'); + } + + return result; + } + + async createOne( + entityName: string, + tableName: string, + args: { data: any }, + workspaceId: string, + info: GraphQLResolveInfo, + fieldAliases: Record, + ) { + if (!this.environmentService.isFlexibleBackendEnabled()) { + throw new ForbiddenException(); + } + + const workspaceDataSource = + await this.dataSourceService.connectToWorkspaceDataSource(workspaceId); + + const graphqlQuery = await this.prepareGrapQLQuery( + workspaceId, + info, + fieldAliases, + ); + + await workspaceDataSource?.query(` + SET search_path TO ${this.dataSourceService.getSchemaName(workspaceId)}; + `); + const graphqlResult = await workspaceDataSource?.query(` + SELECT graphql.resolve($$ + mutation { + createOne${pascalCase( + entityName, + )}: insertInto${tableName}Collection(objects: [${stringify({ + id: uuidv4(), + ...args.data, + })}]) { + affectedCount + records { + ${graphqlQuery} + } + } + } + $$); + `); + + const result = + graphqlResult?.[0]?.resolve?.data?.[`createOne${pascalCase(entityName)}`] + ?.records[0]; + + if (!result) { + throw new BadRequestException('Malformed result from GraphQL query'); + } + + return result; + } + + async createMany( + entityName: string, + tableName: string, + args: { data: any[] }, + workspaceId: string, + info: GraphQLResolveInfo, + fieldAliases: Record, + ) { + if (!this.environmentService.isFlexibleBackendEnabled()) { + throw new ForbiddenException(); + } + + const workspaceDataSource = + await this.dataSourceService.connectToWorkspaceDataSource(workspaceId); + + const graphqlQuery = await this.prepareGrapQLQuery( + workspaceId, + info, + fieldAliases, + ); + + await workspaceDataSource?.query(` + SET search_path TO ${this.dataSourceService.getSchemaName(workspaceId)}; + `); + const graphqlResult = await workspaceDataSource?.query(` + SELECT graphql.resolve($$ + mutation { + insertInto${entityName}Collection: insertInto${tableName}Collection(objects: ${stringify( + args.data.map((datum) => ({ + id: uuidv4(), + ...datum, + })), + )}) { + affectedCount + records { + ${graphqlQuery} + } + } + } + $$); + `); + + const result = + graphqlResult?.[0]?.resolve?.data?.[`insertInto${entityName}Collection`] + ?.records; + + if (!result) { + throw new BadRequestException('Malformed result from GraphQL query'); + } + + return result; + } + + async updateOne( + entityName: string, + tableName: string, + args: { id: string; data: any }, + workspaceId: string, + info: GraphQLResolveInfo, + fieldAliases: Record, + ) { + if (!this.environmentService.isFlexibleBackendEnabled()) { + throw new ForbiddenException(); + } + + const workspaceDataSource = + await this.dataSourceService.connectToWorkspaceDataSource(workspaceId); + + const graphqlQuery = await this.prepareGrapQLQuery( + workspaceId, + info, + fieldAliases, + ); + + await workspaceDataSource?.query(` + SET search_path TO ${this.dataSourceService.getSchemaName(workspaceId)}; + `); + const graphqlResult = await workspaceDataSource?.query(` + SELECT graphql.resolve($$ + mutation { + updateOne${pascalCase( + entityName, + )}: update${tableName}Collection(set: ${stringify( + args.data, + )}, filter: { id: { eq: "${args.id}" } }) { + affectedCount + records { + ${graphqlQuery} + } + } + } + $$); + `); + + const result = + graphqlResult?.[0]?.resolve?.data?.[`updateOne${pascalCase(entityName)}`] + ?.records[0]; + + if (!result) { + throw new BadRequestException('Malformed result from GraphQL query'); } return result; diff --git a/server/src/tenant/schema-generation/graphql-types/object.graphql-type.ts b/server/src/tenant/schema-generation/graphql-types/object.graphql-type.ts index 1e4317542..88abe166f 100644 --- a/server/src/tenant/schema-generation/graphql-types/object.graphql-type.ts +++ b/server/src/tenant/schema-generation/graphql-types/object.graphql-type.ts @@ -2,6 +2,7 @@ import { GraphQLBoolean, GraphQLEnumType, GraphQLID, + GraphQLInputObjectType, GraphQLInt, GraphQLNonNull, GraphQLObjectType, @@ -51,6 +52,7 @@ const mapColumnTypeToGraphQLType = (column: FieldMetadata): any => { * 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 = ( name: string, @@ -82,6 +84,64 @@ export const generateObjectType = ( }); }; +/** + * 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 = {}; + + columns.forEach((column) => { + let graphqlType = mapColumnTypeToGraphQLType(column); + + if (!column.isNullable) { + graphqlType = new GraphQLNonNull(graphqlType); + } + + fields[column.displayName] = { + type: graphqlType, + description: column.targetColumnName, + }; + }); + + return new GraphQLInputObjectType({ + name: `${pascalCase(name)}CreateInput`, + fields, + }); +}; + +/** + * 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 = {}; + + 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, + }); +}; + /** * Generate multiple GraphQL object types based on an array of object metadata. * @param objectMetadata Array of ObjectMetadata. diff --git a/server/src/tenant/schema-generation/schema-generation.service.ts b/server/src/tenant/schema-generation/schema-generation.service.ts index c7556dd19..d2c7635ad 100644 --- a/server/src/tenant/schema-generation/schema-generation.service.ts +++ b/server/src/tenant/schema-generation/schema-generation.service.ts @@ -2,6 +2,8 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { GraphQLID, + GraphQLInputObjectType, + GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLResolveInfo, @@ -16,7 +18,11 @@ import { ObjectMetadata } from 'src/metadata/object-metadata/object-metadata.ent import { generateEdgeType } from './graphql-types/edge.graphql-type'; import { generateConnectionType } from './graphql-types/connection.graphql-type'; -import { generateObjectTypes } from './graphql-types/object.graphql-type'; +import { + generateCreateInputType, + generateObjectType, + generateUpdateInputType, +} from './graphql-types/object.graphql-type'; @Injectable() export class SchemaGenerationService { @@ -46,7 +52,7 @@ export class SchemaGenerationService { const ConnectionType = generateConnectionType(EdgeType); return { - [`findAll${pascalCase(entityName)}`]: { + [`findMany${pascalCase(entityName)}`]: { type: ConnectionType, resolve: async (root, args, context, info: GraphQLResolveInfo) => { return this.entityResolverService.findAll( @@ -77,39 +83,142 @@ export class SchemaGenerationService { }; } - private generateQueryType( - ObjectTypes: Record, + private generateMutationFieldForEntity( + entityName: string, + tableName: string, + ObjectType: GraphQLObjectType, + CreateInputType: GraphQLInputObjectType, + UpdateInputType: GraphQLInputObjectType, + objectDefinition: ObjectMetadata, + workspaceId: string, + ) { + const fieldAliases = + objectDefinition?.fields.reduce( + (acc, field) => ({ + ...acc, + [field.displayName]: field.targetColumnName, + }), + {}, + ) || {}; + + return { + [`createOne${pascalCase(entityName)}`]: { + type: new GraphQLNonNull(ObjectType), + args: { + data: { type: new GraphQLNonNull(CreateInputType) }, + }, + resolve: (root, args, context, info) => { + return this.entityResolverService.createOne( + entityName, + tableName, + args, + workspaceId, + info, + fieldAliases, + ); + }, + }, + [`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( + entityName, + tableName, + args, + workspaceId, + info, + fieldAliases, + ); + }, + }, + [`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( + entityName, + tableName, + args, + workspaceId, + info, + fieldAliases, + ); + }, + }, + }; + } + + private generateQueryAndMutationTypes( objectMetadata: ObjectMetadata[], workspaceId: string, - ): GraphQLObjectType { - const fields: any = {}; + ): { query: GraphQLObjectType; mutation: GraphQLObjectType } { + const queryFields: any = {}; + const mutationFields: any = {}; - for (const [entityName, ObjectType] of Object.entries(ObjectTypes)) { - const objectDefinition = objectMetadata.find( - (object) => object.displayName === entityName, - ); + 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( - fields, + queryFields, this.generateQueryFieldForEntity( - entityName, + objectDefinition.displayName, tableName, ObjectType, objectDefinition, workspaceId, ), ); + + Object.assign( + mutationFields, + this.generateMutationFieldForEntity( + objectDefinition.displayName, + tableName, + ObjectType, + CreateInputType, + UpdateInputType, + objectDefinition, + workspaceId, + ), + ); } - return new GraphQLObjectType({ - name: 'Query', - fields, - }); + return { + query: new GraphQLObjectType({ + name: 'Query', + fields: queryFields, + }), + mutation: new GraphQLObjectType({ + name: 'Mutation', + fields: mutationFields, + }), + }; } async generateSchema( @@ -136,15 +245,14 @@ export class SchemaGenerationService { dataSourceMetadata.id, ); - const ObjectTypes = generateObjectTypes(objectMetadata); - const QueryType = this.generateQueryType( - ObjectTypes, + const { query, mutation } = this.generateQueryAndMutationTypes( objectMetadata, workspaceId, ); return new GraphQLSchema({ - query: QueryType, + query, + mutation, }); } }