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

@@ -17,12 +17,12 @@ import { PrismaModule } from './database/prisma.module';
import { HealthModule } from './health/health.module';
import { AbilityModule } from './ability/ability.module';
import { TenantModule } from './tenant/tenant.module';
import { SchemaGenerationService } from './tenant/schema-generation/schema-generation.service';
import { EnvironmentService } from './integrations/environment/environment.service';
import {
JwtAuthStrategy,
JwtPayload,
} from './core/auth/strategies/jwt.auth.strategy';
import { TenantService } from './tenant/tenant.service';
@Module({
imports: [
@@ -37,7 +37,7 @@ import {
conditionalSchema: async (request) => {
try {
// Get the SchemaGenerationService from the AppModule
const service = AppModule.moduleRef.get(SchemaGenerationService, {
const tenantService = AppModule.moduleRef.get(TenantService, {
strict: false,
});
@@ -57,8 +57,8 @@ import {
// Extract JWT from the request
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request.req);
// If there is no token, return an empty schema
if (!token) {
// If there is no token or flexible backend is disabled, return an empty schema
if (!token || !environmentService.isFlexibleBackendEnabled()) {
return new GraphQLSchema({});
}
@@ -73,7 +73,9 @@ import {
decoded as JwtPayload,
);
const conditionalSchema = await service.generateSchema(workspace.id);
const conditionalSchema = await tenantService.createTenantSchema(
workspace.id,
);
return conditionalSchema;
} catch (error) {

View File

@@ -1,7 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { EntityResolverService } from './entity-resolver.service';
@@ -16,10 +15,6 @@ describe('EntityResolverService', () => {
provide: DataSourceService,
useValue: {},
},
{
provide: EnvironmentService,
useValue: {},
},
],
}).compile();

View File

@@ -1,295 +1,84 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { GraphQLResolveInfo } from 'graphql';
import graphqlFields from 'graphql-fields';
import { v4 as uuidv4 } from 'uuid';
import { SchemaBuilderContext } from 'src/tenant/schema-builder/interfaces/schema-builder-context.interface';
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;
}
import { PGGraphQLQueryRunner } from './utils/pg-graphql-query-runner.util';
@Injectable()
export class EntityResolverService {
constructor(
private readonly dataSourceService: DataSourceService,
private readonly environmentService: EnvironmentService,
) {}
constructor(private readonly dataSourceService: DataSourceService) {}
async findAll(
entityName: string,
tableName: string,
workspaceId: string,
info: GraphQLResolveInfo,
fieldAliases: Record<string, string>,
) {
if (!this.environmentService.isFlexibleBackendEnabled()) {
throw new ForbiddenException();
}
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
const graphqlQuery = await this.prepareGrapQLQuery(
workspaceId,
async findMany(context: SchemaBuilderContext, info: GraphQLResolveInfo) {
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
entityName: context.entityName,
tableName: context.tableName,
workspaceId: context.workspaceId,
info,
fieldAliases,
);
fieldAliases: context.fieldAliases,
});
/* TODO: This is a temporary solution to set the schema before each raw query.
getSchemaName is used to avoid a call to metadata.data_source table,
this won't work when we won't be able to dynamically recompute the schema name from its workspace_id only (remote schemas for example)
*/
await workspaceDataSource?.query(`
SET search_path TO ${this.dataSourceService.getSchemaName(workspaceId)};
`);
const graphqlResult = await workspaceDataSource?.query(`
SELECT graphql.resolve($$
{
findAll${pascalCase(entityName)}: ${tableName}Collection {
${graphqlQuery}
}
}
$$);
`);
const result =
graphqlResult?.[0]?.resolve?.data?.[`findAll${pascalCase(entityName)}`];
if (!result) {
throw new BadRequestException('Malformed result from GraphQL query');
}
return result;
return runner.findMany();
}
async findOne(
entityName: string,
tableName: string,
args: { id: string },
workspaceId: string,
context: SchemaBuilderContext,
info: GraphQLResolveInfo,
fieldAliases: Record<string, string>,
) {
if (!this.environmentService.isFlexibleBackendEnabled()) {
throw new ForbiddenException();
}
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
const graphqlQuery = await this.prepareGrapQLQuery(
workspaceId,
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
entityName: context.entityName,
tableName: context.tableName,
workspaceId: context.workspaceId,
info,
fieldAliases,
);
fieldAliases: context.fieldAliases,
});
await workspaceDataSource?.query(`
SET search_path TO ${this.dataSourceService.getSchemaName(workspaceId)};
`);
const graphqlResult = await workspaceDataSource?.query(`
SELECT graphql.resolve($$
{
findOne${pascalCase(
entityName,
)}: ${tableName}Collection(filter: { id: { eq: "${args.id}" } }) {
${graphqlQuery}
}
}
$$);
`);
const result =
graphqlResult?.[0]?.resolve?.data?.[`findOne${pascalCase(entityName)}`];
if (!result) {
throw new BadRequestException('Malformed result from GraphQL query');
}
return result;
return runner.findOne(args);
}
async createOne(
entityName: string,
tableName: string,
args: { data: any },
workspaceId: string,
context: SchemaBuilderContext,
info: GraphQLResolveInfo,
fieldAliases: Record<string, string>,
) {
if (!this.environmentService.isFlexibleBackendEnabled()) {
throw new ForbiddenException();
}
const records = await this.createMany({ data: [args.data] }, context, info);
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;
return records?.[0];
}
async createMany(
entityName: string,
tableName: string,
args: { data: any[] },
workspaceId: string,
context: SchemaBuilderContext,
info: GraphQLResolveInfo,
fieldAliases: Record<string, string>,
) {
if (!this.environmentService.isFlexibleBackendEnabled()) {
throw new ForbiddenException();
}
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
const graphqlQuery = await this.prepareGrapQLQuery(
workspaceId,
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
entityName: context.entityName,
tableName: context.tableName,
workspaceId: context.workspaceId,
info,
fieldAliases,
);
fieldAliases: context.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;
return runner.createMany(args);
}
async updateOne(
entityName: string,
tableName: string,
args: { id: string; data: any },
workspaceId: string,
context: SchemaBuilderContext,
info: GraphQLResolveInfo,
fieldAliases: Record<string, string>,
) {
if (!this.environmentService.isFlexibleBackendEnabled()) {
throw new ForbiddenException();
}
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
const graphqlQuery = await this.prepareGrapQLQuery(
workspaceId,
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
entityName: context.entityName,
tableName: context.tableName,
workspaceId: context.workspaceId,
info,
fieldAliases,
);
fieldAliases: context.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;
}
private async prepareGrapQLQuery(
workspaceId: string,
info: GraphQLResolveInfo,
fieldAliases: Record<string, string>,
): Promise<string> {
// Extract requested fields from GraphQL resolve info
const fields = graphqlFields(info);
await this.dataSourceService.createWorkspaceSchema(workspaceId);
const graphqlQuery = convertFieldsToGraphQL(fields, fieldAliases);
return graphqlQuery;
return runner.updateOne(args);
}
}

View File

@@ -0,0 +1,128 @@
import { GraphQLResolveInfo } from 'graphql';
import graphqlFields from 'graphql-fields';
import { v4 as uuidv4 } from 'uuid';
import { pascalCase } from 'src/utils/pascal-case';
import { stringifyWithoutKeyQuote } from './stringify-without-key-quote.util';
import { convertFieldsToGraphQL } from './convert-fields-to-graphql.util';
type Command = 'findMany' | 'findOne' | 'createMany' | 'updateOne';
type CommandArgs = {
findMany: null;
findOne: { id: string };
createMany: { data: any[] };
updateOne: { id: string; data: any };
};
export interface PGGraphQLQueryBuilderOptions {
entityName: string;
tableName: string;
info: GraphQLResolveInfo;
fieldAliases: Record<string, string>;
}
export class PGGraphQLQueryBuilder {
private options: PGGraphQLQueryBuilderOptions;
private command: Command;
private commandArgs: any;
constructor(options: PGGraphQLQueryBuilderOptions) {
this.options = options;
}
private getFields(): string {
const fields = graphqlFields(this.options.info);
return convertFieldsToGraphQL(fields, this.options.fieldAliases);
}
// Define command setters
findMany() {
this.command = 'findMany';
this.commandArgs = null;
return this;
}
findOne(args: CommandArgs['findOne']) {
this.command = 'findOne';
this.commandArgs = args;
return this;
}
createMany(args: CommandArgs['createMany']) {
this.command = 'createMany';
this.commandArgs = args;
return this;
}
updateOne(args: CommandArgs['updateOne']) {
this.command = 'updateOne';
this.commandArgs = args;
return this;
}
build() {
const { entityName, tableName } = this.options;
const fields = this.getFields();
switch (this.command) {
case 'findMany':
return `
query FindMany${pascalCase(entityName)} {
findMany${pascalCase(entityName)}: ${tableName}Collection {
${fields}
}
}
`;
case 'findOne':
return `
query FindOne${pascalCase(entityName)} {
findOne${pascalCase(
entityName,
)}: ${tableName}Collection(filter: { id: { eq: "${
this.commandArgs.id
}" } }) {
${fields}
}
}
`;
case 'createMany':
return `
mutation CreateMany${pascalCase(entityName)} {
createMany${pascalCase(
entityName,
)}: insertInto${tableName}Collection(objects: ${stringifyWithoutKeyQuote(
this.commandArgs.data.map((datum) => ({
id: uuidv4(),
...datum,
})),
)}) {
affectedCount
records {
${fields}
}
}
}
`;
case 'updateOne':
return `
mutation UpdateOne${pascalCase(entityName)} {
updateOne${pascalCase(
entityName,
)}: update${tableName}Collection(set: ${stringifyWithoutKeyQuote(
this.commandArgs.data,
)}, filter: { id: { eq: "${this.commandArgs.id}" } }) {
affectedCount
records {
${fields}
}
}
}
`;
default:
throw new Error('Invalid command');
}
}
}

View File

@@ -0,0 +1,94 @@
import { BadRequestException } from '@nestjs/common';
import { GraphQLResolveInfo } from 'graphql';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { pascalCase } from 'src/utils/pascal-case';
import { PGGraphQLQueryBuilder } from './pg-graphql-query-builder.util';
interface QueryRunnerOptions {
entityName: string;
tableName: string;
workspaceId: string;
info: GraphQLResolveInfo;
fieldAliases: Record<string, string>;
}
export class PGGraphQLQueryRunner {
private queryBuilder: PGGraphQLQueryBuilder;
private options: QueryRunnerOptions;
constructor(
private dataSourceService: DataSourceService,
options: QueryRunnerOptions,
) {
this.queryBuilder = new PGGraphQLQueryBuilder({
entityName: options.entityName,
tableName: options.tableName,
info: options.info,
fieldAliases: options.fieldAliases,
});
this.options = options;
}
private async execute(query: string, workspaceId: string): Promise<any> {
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
await workspaceDataSource?.query(`
SET search_path TO ${this.dataSourceService.getSchemaName(workspaceId)};
`);
return workspaceDataSource?.query(`
SELECT graphql.resolve($$
${query}
$$);
`);
}
private parseResults(graphqlResult: any, command: string): any {
const entityKey = `${command}${pascalCase(this.options.entityName)}`;
const result = graphqlResult?.[0]?.resolve?.data?.[entityKey];
if (!result) {
throw new BadRequestException('Malformed result from GraphQL query');
}
return result;
}
async findMany(): Promise<any[]> {
const query = this.queryBuilder.findMany().build();
const result = await this.execute(query, this.options.workspaceId);
return this.parseResults(result, 'findMany');
}
async findOne(args: { id: string }): Promise<any> {
const query = this.queryBuilder.findOne(args).build();
const result = await this.execute(query, this.options.workspaceId);
return this.parseResults(result, 'findOne');
}
async createMany(args: { data: any[] }): Promise<any[]> {
const query = this.queryBuilder.createMany(args).build();
const result = await this.execute(query, this.options.workspaceId);
return this.parseResults(result, 'createMany')?.records;
}
async createOne(args: { data: any }): Promise<any> {
const records = await this.createMany({ data: [args.data] });
return records?.[0];
}
async updateOne(args: { id: string; data: any }): Promise<any> {
const query = this.queryBuilder.updateOne(args).build();
const result = await this.execute(query, this.options.workspaceId);
return this.parseResults(result, 'updateOne')?.records?.[0];
}
}

View File

@@ -0,0 +1,5 @@
export const stringifyWithoutKeyQuote = (obj: any) => {
const jsonString = JSON.stringify(obj);
const jsonWithoutQuotes = jsonString.replace(/"(\w+)"\s*:/g, '$1:');
return jsonWithoutQuotes;
};

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

@@ -1,43 +1,37 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import {
GraphQLFieldConfigMap,
GraphQLID,
GraphQLInputObjectType,
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
GraphQLResolveInfo,
GraphQLSchema,
} from 'graphql';
import { EntityResolverService } from 'src/tenant/entity-resolver/entity-resolver.service';
import { DataSourceMetadataService } from 'src/metadata/data-source-metadata/data-source-metadata.service';
import { pascalCase } from 'src/utils/pascal-case';
import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service';
import { ObjectMetadata } from 'src/metadata/object-metadata/object-metadata.entity';
import { generateEdgeType } from './graphql-types/edge.graphql-type';
import { generateConnectionType } from './graphql-types/connection.graphql-type';
import {
generateCreateInputType,
generateObjectType,
generateUpdateInputType,
} from './graphql-types/object.graphql-type';
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 SchemaGenerationService {
constructor(
private readonly dataSourceMetadataService: DataSourceMetadataService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly entityResolverService: EntityResolverService,
) {}
export class SchemaBuilderService {
workspaceId: string;
constructor(private readonly entityResolverService: EntityResolverService) {}
private generateQueryFieldForEntity(
entityName: string,
tableName: string,
ObjectType: GraphQLObjectType,
objectDefinition: ObjectMetadata,
workspaceId: string,
) {
const fieldAliases =
objectDefinition?.fields.reduce(
@@ -47,6 +41,12 @@ export class SchemaGenerationService {
}),
{},
) || {};
const schemaBuilderContext: SchemaBuilderContext = {
entityName,
tableName,
workspaceId: this.workspaceId,
fieldAliases,
};
const EdgeType = generateEdgeType(ObjectType);
const ConnectionType = generateConnectionType(EdgeType);
@@ -54,13 +54,10 @@ export class SchemaGenerationService {
return {
[`findMany${pascalCase(entityName)}`]: {
type: ConnectionType,
resolve: async (root, args, context, info: GraphQLResolveInfo) => {
return this.entityResolverService.findAll(
entityName,
tableName,
workspaceId,
resolve: async (root, args, context, info) => {
return this.entityResolverService.findMany(
schemaBuilderContext,
info,
fieldAliases,
);
},
},
@@ -71,16 +68,13 @@ export class SchemaGenerationService {
},
resolve: (root, args, context, info) => {
return this.entityResolverService.findOne(
entityName,
tableName,
args,
workspaceId,
schemaBuilderContext,
info,
fieldAliases,
);
},
},
};
} as GraphQLFieldConfigMap<any, any>;
}
private generateMutationFieldForEntity(
@@ -90,7 +84,6 @@ export class SchemaGenerationService {
CreateInputType: GraphQLInputObjectType,
UpdateInputType: GraphQLInputObjectType,
objectDefinition: ObjectMetadata,
workspaceId: string,
) {
const fieldAliases =
objectDefinition?.fields.reduce(
@@ -100,6 +93,12 @@ export class SchemaGenerationService {
}),
{},
) || {};
const schemaBuilderContext: SchemaBuilderContext = {
entityName,
tableName,
workspaceId: this.workspaceId,
fieldAliases,
};
return {
[`createOne${pascalCase(entityName)}`]: {
@@ -109,12 +108,9 @@ export class SchemaGenerationService {
},
resolve: (root, args, context, info) => {
return this.entityResolverService.createOne(
entityName,
tableName,
args,
workspaceId,
schemaBuilderContext,
info,
fieldAliases,
);
},
},
@@ -129,12 +125,9 @@ export class SchemaGenerationService {
},
resolve: (root, args, context, info) => {
return this.entityResolverService.createMany(
entityName,
tableName,
args,
workspaceId,
schemaBuilderContext,
info,
fieldAliases,
);
},
},
@@ -146,22 +139,19 @@ export class SchemaGenerationService {
},
resolve: (root, args, context, info) => {
return this.entityResolverService.updateOne(
entityName,
tableName,
args,
workspaceId,
schemaBuilderContext,
info,
fieldAliases,
);
},
},
};
} as GraphQLFieldConfigMap<any, any>;
}
private generateQueryAndMutationTypes(
objectMetadata: ObjectMetadata[],
workspaceId: string,
): { query: GraphQLObjectType; mutation: GraphQLObjectType } {
private generateQueryAndMutationTypes(objectMetadata: ObjectMetadata[]): {
query: GraphQLObjectType;
mutation: GraphQLObjectType;
} {
const queryFields: any = {};
const mutationFields: any = {};
@@ -191,7 +181,6 @@ export class SchemaGenerationService {
tableName,
ObjectType,
objectDefinition,
workspaceId,
),
);
@@ -204,7 +193,6 @@ export class SchemaGenerationService {
CreateInputType,
UpdateInputType,
objectDefinition,
workspaceId,
),
);
}
@@ -222,33 +210,13 @@ export class SchemaGenerationService {
}
async generateSchema(
workspaceId: string | undefined,
workspaceId: string,
objectMetadata: ObjectMetadata[],
): Promise<GraphQLSchema> {
if (!workspaceId) {
return new GraphQLSchema({});
}
this.workspaceId = workspaceId;
const dataSourcesMetadata =
await this.dataSourceMetadataService.getDataSourcesMetadataFromWorkspaceId(
workspaceId,
);
// Can'f find any data sources for this workspace
if (!dataSourcesMetadata || dataSourcesMetadata.length === 0) {
return new GraphQLSchema({});
}
const dataSourceMetadata = dataSourcesMetadata[0];
const objectMetadata =
await this.objectMetadataService.getObjectMetadataFromDataSourceId(
dataSourceMetadata.id,
);
const { query, mutation } = this.generateQueryAndMutationTypes(
objectMetadata,
workspaceId,
);
const { query, mutation } =
this.generateQueryAndMutationTypes(objectMetadata);
return new GraphQLSchema({
query,

View File

@@ -1,6 +1,6 @@
import { GraphQLList, GraphQLNonNull, GraphQLObjectType } from 'graphql';
import { PageInfoType } from './page-info.graphql-type';
import { PageInfoType } from './page-into-type.util';
/**
* Generate a GraphQL connection type based on the EdgeType.

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,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

@@ -1,159 +0,0 @@
import {
GraphQLBoolean,
GraphQLEnumType,
GraphQLID,
GraphQLInputObjectType,
GraphQLInt,
GraphQLNonNull,
GraphQLObjectType,
GraphQLString,
} from 'graphql';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { ObjectMetadata } from 'src/metadata/object-metadata/object-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.
*/
const mapColumnTypeToGraphQLType = (column: FieldMetadata): any => {
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;
}
};
/**
* 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: Record<string, any> = {
// Default fields
id: { type: new GraphQLNonNull(GraphQLID) },
createdAt: { type: new GraphQLNonNull(GraphQLString) },
updatedAt: { type: new GraphQLNonNull(GraphQLString) },
};
columns.forEach((column) => {
let graphqlType = mapColumnTypeToGraphQLType(column);
if (!column.isNullable) {
graphqlType = new GraphQLNonNull(graphqlType);
}
fields[column.displayName] = {
type: graphqlType,
description: column.targetColumnName,
};
});
return new GraphQLObjectType({
name: pascalCase(name),
fields,
});
};
/**
* 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) => {
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<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,
});
};
/**
* Generate multiple GraphQL object types based on an array of object metadata.
* @param objectMetadata Array of ObjectMetadata.
*/
export const generateObjectTypes = (objectMetadata: ObjectMetadata[]) => {
const objectTypes: Record<string, GraphQLObjectType> = {};
for (const object of objectMetadata) {
const ObjectType = generateObjectType(object.displayName, object.fields);
objectTypes[object.displayName] = ObjectType;
}
return objectTypes;
};

View File

@@ -1,19 +0,0 @@
import { Module } from '@nestjs/common';
import { EntityResolverModule } from 'src/tenant/entity-resolver/entity-resolver.module';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { DataSourceMetadataModule } from 'src/metadata/data-source-metadata/data-source-metadata.module';
import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module';
import { SchemaGenerationService } from './schema-generation.service';
@Module({
imports: [
EntityResolverModule,
DataSourceMetadataModule,
ObjectMetadataModule,
],
providers: [SchemaGenerationService, JwtAuthGuard],
exports: [SchemaGenerationService],
})
export class SchemaGenerationModule {}

View File

@@ -1,11 +1,21 @@
import { Module } from '@nestjs/common';
import { MetadataModule } from 'src/metadata/metadata.module';
import { DataSourceMetadataModule } from 'src/metadata/data-source-metadata/data-source-metadata.module';
import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module';
import { UniversalModule } from './universal/universal.module';
import { SchemaGenerationModule } from './schema-generation/schema-generation.module';
import { TenantService } from './tenant.service';
import { SchemaBuilderModule } from './schema-builder/schema-builder.module';
@Module({
imports: [MetadataModule, UniversalModule, SchemaGenerationModule],
imports: [
MetadataModule,
SchemaBuilderModule,
DataSourceMetadataModule,
ObjectMetadataModule,
],
providers: [TenantService],
exports: [TenantService],
})
export class TenantModule {}

View File

@@ -2,17 +2,22 @@ import { Test, TestingModule } from '@nestjs/testing';
import { DataSourceMetadataService } from 'src/metadata/data-source-metadata/data-source-metadata.service';
import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service';
import { EntityResolverService } from 'src/tenant/entity-resolver/entity-resolver.service';
import { SchemaGenerationService } from './schema-generation.service';
import { TenantService } from './tenant.service';
describe('SchemaGenerationService', () => {
let service: SchemaGenerationService;
import { SchemaBuilderService } from './schema-builder/schema-builder.service';
describe('TenantService', () => {
let service: TenantService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SchemaGenerationService,
TenantService,
{
provide: SchemaBuilderService,
useValue: {},
},
{
provide: DataSourceMetadataService,
useValue: {},
@@ -21,14 +26,10 @@ describe('SchemaGenerationService', () => {
provide: ObjectMetadataService,
useValue: {},
},
{
provide: EntityResolverService,
useValue: {},
},
],
}).compile();
service = module.get<SchemaGenerationService>(SchemaGenerationService);
service = module.get<TenantService>(TenantService);
});
it('should be defined', () => {

View File

@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { GraphQLSchema } from 'graphql';
import { DataSourceMetadataService } from 'src/metadata/data-source-metadata/data-source-metadata.service';
import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service';
import { SchemaBuilderService } from './schema-builder/schema-builder.service';
@Injectable()
export class TenantService {
constructor(
private readonly schemaBuilderService: SchemaBuilderService,
private readonly dataSourceMetadataService: DataSourceMetadataService,
private readonly objectMetadataService: ObjectMetadataService,
) {}
async createTenantSchema(workspaceId: string | undefined) {
if (!workspaceId) {
return new GraphQLSchema({});
}
const dataSourcesMetadata =
await this.dataSourceMetadataService.getDataSourcesMetadataFromWorkspaceId(
workspaceId,
);
// Can'f find any data sources for this workspace
if (!dataSourcesMetadata || dataSourcesMetadata.length === 0) {
return new GraphQLSchema({});
}
const dataSourceMetadata = dataSourcesMetadata[0];
const objectMetadata =
await this.objectMetadataService.getObjectMetadataFromDataSourceId(
dataSourceMetadata.id,
);
return this.schemaBuilderService.generateSchema(
workspaceId,
objectMetadata,
);
}
}

View File

@@ -1,11 +0,0 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@ArgsType()
export class BaseUniversalArgs {
@Field(() => String)
@IsNotEmpty()
@IsString()
entity: string;
}

View File

@@ -1,10 +0,0 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { BaseUniversalArgs } from './base-universal.args';
import { UniversalEntityInput } from './universal-entity.input';
@ArgsType()
export class DeleteOneUniversalArgs extends BaseUniversalArgs {
@Field(() => UniversalEntityInput, { nullable: true })
where?: UniversalEntityInput;
}

View File

@@ -1,29 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { registerEnumType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
export enum TypeORMSortOrder {
ASC = 'ASC',
DESC = 'DESC',
}
registerEnumType(TypeORMSortOrder, {
name: 'TypeORMSortOrder',
description: undefined,
});
@InputType()
export class UniversalEntityOrderByRelationInput {
@Field(() => TypeORMSortOrder, { nullable: true })
id?: keyof typeof TypeORMSortOrder;
@Field(() => GraphQLJSON, { nullable: true })
data?: Record<string, keyof typeof TypeORMSortOrder>;
@Field(() => TypeORMSortOrder, { nullable: true })
createdAt?: keyof typeof TypeORMSortOrder;
@Field(() => TypeORMSortOrder, { nullable: true })
updatedAt?: keyof typeof TypeORMSortOrder;
}

View File

@@ -1,18 +0,0 @@
import { Field, ID, InputType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
@InputType()
export class UniversalEntityInput {
@Field(() => ID, { nullable: true })
id?: string;
@Field(() => GraphQLJSON, { nullable: true })
data?: Record<string, unknown>;
@Field(() => Date, { nullable: true })
createdAt?: Date;
@Field(() => Date, { nullable: true })
updatedAt?: Date;
}

View File

@@ -1,13 +0,0 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { BaseUniversalArgs } from './base-universal.args';
import { UniversalEntityInput } from './universal-entity.input';
@ArgsType()
export class UpdateOneCustomArgs extends BaseUniversalArgs {
@Field(() => UniversalEntityInput, { nullable: false })
data!: UniversalEntityInput;
@Field(() => UniversalEntityInput, { nullable: true })
where?: UniversalEntityInput;
}

View File

@@ -1,25 +0,0 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { Paginated } from 'src/utils/pagination';
@ObjectType()
export class UniversalEntity {
@Field(() => ID, { nullable: false })
id!: string;
@Field(() => GraphQLJSON, { nullable: false })
data!: Record<string, unknown>;
@Field(() => Date, { nullable: false })
createdAt!: Date;
@Field(() => Date, { nullable: false })
updatedAt!: Date;
}
@ObjectType()
export class PaginatedUniversalEntity extends Paginated<UniversalEntity>(
UniversalEntity,
) {}

View File

@@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
import { UniversalResolver } from './universal.resolver';
@Module({
imports: [DataSourceModule],
providers: [UniversalResolver],
})
export class UniversalModule {}

View File

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

View File

@@ -1,41 +0,0 @@
import { Query, Resolver } from '@nestjs/graphql';
import { ForbiddenException, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { UniversalEntity } from './universal.entity';
@UseGuards(JwtAuthGuard)
@Resolver(() => UniversalEntity)
export class UniversalResolver {
constructor(private readonly environmentService: EnvironmentService) {}
@Query(() => UniversalEntity)
updateOneCustom(): UniversalEntity {
if (!this.environmentService.isFlexibleBackendEnabled()) {
throw new ForbiddenException();
}
return {
id: 'exampleId',
data: {},
createdAt: new Date(),
updatedAt: new Date(),
};
}
@Query(() => UniversalEntity)
deleteOneCustom(): UniversalEntity {
if (!this.environmentService.isFlexibleBackendEnabled()) {
throw new ForbiddenException();
}
return {
id: 'exampleId',
data: {},
createdAt: new Date(),
updatedAt: new Date(),
};
}
}

View File

@@ -1,59 +0,0 @@
import { snakeCase } from 'src/utils/snake-case';
import { UniversalEntityInput } from './args/universal-entity.input';
import {
UniversalEntityOrderByRelationInput,
TypeORMSortOrder,
} from './args/universal-entity-order-by-relation.input';
export const getRawTypeORMWhereClause = (
entity: string,
where?: UniversalEntityInput | undefined,
) => {
if (!where) {
return {
where: '',
parameters: {},
};
}
const { id, data, createdAt, updatedAt } = where;
const flattenWhere: any = {
...(id ? { id } : {}),
...data,
...(createdAt ? { createdAt } : {}),
...(updatedAt ? { updatedAt } : {}),
};
return {
where: Object.keys(flattenWhere)
.map((key) => `${entity}.${snakeCase(key)} = :${key}`)
.join(' AND '),
parameters: flattenWhere,
};
};
export const getRawTypeORMOrderByClause = (
entity: string,
orderBy?: UniversalEntityOrderByRelationInput | undefined,
) => {
if (!orderBy) {
return {};
}
const { id, data, createdAt, updatedAt } = orderBy;
const flattenWhere: any = {
...(id ? { id } : {}),
...data,
...(createdAt ? { createdAt } : {}),
...(updatedAt ? { updatedAt } : {}),
};
const orderByClause: Record<string, TypeORMSortOrder> = {};
for (const key of Object.keys(flattenWhere)) {
orderByClause[`${entity}.${snakeCase(key)}`] = flattenWhere[key];
}
return orderByClause;
};