feat: dynamic graphQL schema generation based on user workspace (#1725)

* wip: refacto and start creating custom resolver

* feat: findMany & findUnique of a custom entity

* feat: wip pagination

* feat: initial metadata migration

* feat: universal findAll with pagination

* fix: clean small stuff in pagination

* fix: test

* fix: miss file

* feat: rename custom into universal

* feat: create metadata schema in default database

* Multi-tenant db schemas POC

fix tests and use query builders

remove synchronize

restore updatedAt

remove unnecessary import

use queryRunner

fix camelcase

add migrations for standard objects

Multi-tenant db schemas POC

fix tests and use query builders

remove synchronize

restore updatedAt

remove unnecessary import

use queryRunner

fix camelcase

add migrations for standard objects

poc: conditional schema at runtime

wip: try to create resolver in Nest.JS context

fix

* feat: wip add pg_graphql

* feat: setup pg_graphql during database init

* wip: dynamic resolver

* poc: dynamic resolver and query using pg_graphql

* feat: pg_graphql use ARG in Dockerfile

* feat: clean findMany & findOne dynamic resolver

* feat: get correct schema based on access token

* fix: remove old file

* fix: tests

* fix: better comment

* fix: e2e test not working, error format change due to yoga

* remove typeorm entity generation + fix jwt + fix search_path + remove anon

* fix conflict

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: corentin <corentin@twenty.com>
This commit is contained in:
Jérémy M
2023-09-28 16:27:34 +02:00
committed by GitHub
parent 485bc64b4f
commit 629bdbbf50
35 changed files with 1860 additions and 124 deletions

View File

@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ConfigModule } from '@nestjs/config';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { ModuleRef } from '@nestjs/core';
import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default';
import { GraphQLError } from 'graphql';
import { YogaDriver, YogaDriverConfig } from '@graphql-yoga/nestjs';
import GraphQLJSON from 'graphql-type-json';
import { GraphQLError, GraphQLSchema } from 'graphql';
import { ExtractJwt } from 'passport-jwt';
import { TokenExpiredError, verify } from 'jsonwebtoken';
import { AppService } from './app.service';
@ -15,24 +17,77 @@ 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';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
GraphQLModule.forRoot<ApolloDriverConfig>({
playground: false,
GraphQLModule.forRoot<YogaDriverConfig>({
context: ({ req }) => ({ req }),
driver: ApolloDriver,
driver: YogaDriver,
autoSchemaFile: true,
resolvers: { JSON: GraphQLJSON },
plugins: [ApolloServerPluginLandingPageLocalDefault()],
formatError: (error: GraphQLError) => {
error.extensions.stacktrace = undefined;
return error;
conditionalSchema: async (request) => {
try {
// Get the SchemaGenerationService from the AppModule
const service = AppModule.moduleRef.get(SchemaGenerationService, {
strict: false,
});
// Get the JwtAuthStrategy from the AppModule
const jwtStrategy = AppModule.moduleRef.get(JwtAuthStrategy, {
strict: false,
});
// Get the EnvironmentService from the AppModule
const environmentService = AppModule.moduleRef.get(
EnvironmentService,
{
strict: false,
},
);
// Extract JWT from the request
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request.req);
// If there is no token, return an empty schema
if (!token) {
return new GraphQLSchema({});
}
// Verify and decode JWT
const decoded = verify(
token,
environmentService.getAccessTokenSecret(),
);
// Validate JWT
const { workspace } = await jwtStrategy.validate(
decoded as JwtPayload,
);
const conditionalSchema = await service.generateSchema(workspace.id);
return conditionalSchema;
} catch (error) {
if (error instanceof TokenExpiredError) {
throw new GraphQLError('Unauthenticated', {
extensions: {
code: 'UNAUTHENTICATED',
},
});
}
throw error;
}
},
csrfPrevention: false,
resolvers: { JSON: GraphQLJSON },
plugins: [],
}),
PrismaModule,
HealthModule,
@ -43,4 +98,10 @@ import { TenantModule } from './tenant/tenant.module';
],
providers: [AppService],
})
export class AppModule {}
export class AppModule {
static moduleRef: ModuleRef;
constructor(private moduleRef: ModuleRef) {
AppModule.moduleRef = this.moduleRef;
}
}

View File

@ -8,6 +8,7 @@ import { PersonService } from 'src/core/person/person.service';
import { CompanyService } from 'src/core/company/company.service';
import { PipelineProgressService } from 'src/core/pipeline/services/pipeline-progress.service';
import { ViewService } from 'src/core/view/services/view.service';
import { DataSourceService } from 'src/tenant/metadata/data-source/data-source.service';
import { WorkspaceService } from './workspace.service';
@ -46,6 +47,10 @@ describe('WorkspaceService', () => {
provide: ViewService,
useValue: {},
},
{
provide: DataSourceService,
useValue: {},
},
],
}).compile();

View File

@ -11,6 +11,7 @@ import { PipelineService } from 'src/core/pipeline/services/pipeline.service';
import { ViewService } from 'src/core/view/services/view.service';
import { PrismaService } from 'src/database/prisma.service';
import { assert } from 'src/utils/assert';
import { DataSourceService } from 'src/tenant/metadata/data-source/data-source.service';
@Injectable()
export class WorkspaceService {
@ -22,6 +23,7 @@ export class WorkspaceService {
private readonly pipelineStageService: PipelineStageService,
private readonly pipelineProgressService: PipelineProgressService,
private readonly viewService: ViewService,
private readonly dataSourceService: DataSourceService,
) {}
// Find
@ -63,6 +65,9 @@ export class WorkspaceService {
},
});
// Create workspace schema
await this.dataSourceService.createWorkspaceSchema(workspace.id);
// Create default companies
const companies = await this.companyService.createDefaultCompanies({
workspaceId: workspace.id,

View File

@ -5,6 +5,7 @@ import { PipelineModule } from 'src/core/pipeline/pipeline.module';
import { CompanyModule } from 'src/core/company/company.module';
import { PersonModule } from 'src/core/person/person.module';
import { ViewModule } from 'src/core/view/view.module';
import { DataSourceModule } from 'src/tenant/metadata/data-source/data-source.module';
import { WorkspaceService } from './services/workspace.service';
import { WorkspaceMemberService } from './services/workspace-member.service';
@ -12,7 +13,13 @@ import { WorkspaceMemberResolver } from './resolvers/workspace-member.resolver';
import { WorkspaceResolver } from './resolvers/workspace.resolver';
@Module({
imports: [PipelineModule, CompanyModule, PersonModule, ViewModule],
imports: [
PipelineModule,
CompanyModule,
PersonModule,
ViewModule,
DataSourceModule,
],
providers: [
WorkspaceService,
FileUploadService,

View File

@ -17,9 +17,7 @@ export class JwtAuthGuard extends AuthGuard(['jwt']) {
}
getRequest(context: ExecutionContext) {
const request = getRequest(context);
return request;
return getRequest(context);
}
handleRequest(err: any, user: any, info: any) {

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DataSourceModule } from 'src/tenant/metadata/data-source/data-source.module';
import { EntityResolverService } from './entity-resolver.service';
@Module({
imports: [DataSourceModule],
providers: [EntityResolverService],
exports: [EntityResolverService],
})
export class EntityResolverModule {}

View File

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

View File

@ -0,0 +1,111 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { GraphQLResolveInfo } from 'graphql';
import graphqlFields from 'graphql-fields';
import { DataSourceService } from 'src/tenant/metadata/data-source/data-source.service';
import { convertFieldsToGraphQL } from './entity-resolver.util';
@Injectable()
export class EntityResolverService {
constructor(private readonly dataSourceService: DataSourceService) {}
async findAll(
entityName: string,
tableName: string,
workspaceId: string,
info: GraphQLResolveInfo,
fieldAliases: Record<string, string>,
) {
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
const graphqlQuery = await this.prepareGrapQLQuery(
workspaceId,
info,
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($$
{
${entityName}Collection: ${tableName}Collection {
${graphqlQuery}
}
}
$$);
`);
const result =
graphqlResult?.[0]?.resolve?.data?.[`${entityName}Collection`];
if (!result) {
throw new BadRequestException('Malformed result from GraphQL query');
}
return result;
}
async findOne(
entityName: string,
tableName: string,
args: { id: string },
workspaceId: string,
info: GraphQLResolveInfo,
fieldAliases: Record<string, string>,
) {
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($$
{
${entityName}Collection: : ${tableName}Collection(filter: { id: { eq: "${args.id}" } }) {
${graphqlQuery}
}
}
$$);
`);
const result =
graphqlResult?.[0]?.resolve?.data?.[`${entityName}Collection`];
if (!result) {
return null;
}
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;
}
}

View File

@ -0,0 +1,23 @@
import isEmpty from 'lodash.isempty';
export const convertFieldsToGraphQL = (
fields: any,
fieldAliases: Record<string, string>,
acc = '',
) => {
for (const [key, value] of Object.entries(fields)) {
if (value && !isEmpty(value)) {
acc += `${key} {\n`;
acc = convertFieldsToGraphQL(value, fieldAliases, acc);
acc += `}\n`;
} else {
if (fieldAliases[key]) {
acc += `${key}: ${fieldAliases[key]}\n`;
} else {
acc += `${key}\n`;
}
}
}
return acc;
};

View File

@ -28,10 +28,17 @@ export class DataSourceMetadataService {
});
}
getDataSourcesMetadataFromWorkspaceId(workspaceId: string) {
async getDataSourcesMetadataFromWorkspaceId(workspaceId: string) {
return this.dataSourceMetadataRepository.find({
where: { workspaceId },
order: { createdAt: 'DESC' },
});
}
async getLastDataSourceMetadataFromWorkspaceIdOrFail(workspaceId: string) {
return this.dataSourceMetadataRepository.findOneOrFail({
where: { workspaceId },
order: { createdAt: 'DESC' },
});
}
}

View File

@ -1,9 +1,4 @@
import {
Injectable,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { DataSource, QueryRunner, Table } from 'typeorm';
@ -37,15 +32,14 @@ export class DataSourceService implements OnModuleInit, OnModuleDestroy {
* @param workspaceId
* @returns Promise<void>
*/
public async createWorkspaceSchema(workspaceId: string): Promise<void> {
public async createWorkspaceSchema(workspaceId: string): Promise<string> {
const schemaName = this.getSchemaName(workspaceId);
const queryRunner = this.mainDataSource.createQueryRunner();
const schemaAlreadyExists = await queryRunner.hasSchema(schemaName);
if (schemaAlreadyExists) {
throw new Error(
`Schema ${schemaName} already exists for workspace ${workspaceId}`,
);
return schemaName;
}
await queryRunner.createSchema(schemaName, true);
@ -56,6 +50,8 @@ export class DataSourceService implements OnModuleInit, OnModuleDestroy {
workspaceId,
schemaName,
);
return schemaName;
}
private async createMigrationTable(
@ -105,20 +101,12 @@ export class DataSourceService implements OnModuleInit, OnModuleDestroy {
return cachedDataSource;
}
const dataSourcesMetadata =
await this.dataSourceMetadataService.getDataSourcesMetadataFromWorkspaceId(
workspaceId,
);
if (dataSourcesMetadata.length === 0) {
throw new NotFoundException(
`We can't find any data source for this workspace id (${workspaceId}).`,
);
}
// We only want the first one for now, we will handle multiple data sources later with remote datasources.
// However, we will need to differentiate the data sources because we won't run migrations on remote data sources for example.
const dataSourceMetadata = dataSourcesMetadata[0];
const dataSourceMetadata =
await this.dataSourceMetadataService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
workspaceId,
);
const schema = dataSourceMetadata.schema;
// Probably not needed as we will ask for the schema name OR store public by default if it's remote
@ -128,11 +116,6 @@ export class DataSourceService implements OnModuleInit, OnModuleDestroy {
);
}
const entities =
await this.entitySchemaGeneratorService.getTypeORMEntitiesByDataSourceId(
dataSourceMetadata.id,
);
const workspaceDataSource = new DataSource({
// TODO: We should use later dataSourceMetadata.type and use a switch case condition to create the right data source
url: dataSourceMetadata.url ?? this.environmentService.getPGDatabaseUrl(),
@ -141,15 +124,17 @@ export class DataSourceService implements OnModuleInit, OnModuleDestroy {
schema,
entities: {
TenantMigration,
...entities,
},
});
await workspaceDataSource.initialize();
// Set search path to workspace schema for raw queries
await workspaceDataSource?.query(`SET search_path TO ${schema};`);
this.dataSources.set(workspaceId, workspaceDataSource);
return this.dataSources.get(workspaceId);
return workspaceDataSource;
}
/**

View File

@ -31,7 +31,6 @@ export const sanitizeColumnName = (columnName: string): string =>
export const convertFieldTypeToPostgresType = (fieldType: string): string => {
switch (fieldType) {
case 'text':
return 'text';
case 'url':
return 'text';
case 'number':

View File

@ -27,9 +27,15 @@ export class FieldMetadata {
@Column({ nullable: false, name: 'target_column_name' })
targetColumnName: string;
@Column('text', { nullable: true, array: true })
enums: string[];
@Column({ default: false, name: 'is_custom' })
isCustom: boolean;
@Column({ nullable: true, default: true, name: 'is_nullable' })
isNullable: boolean;
@Column({ nullable: false, name: 'workspace_id' })
workspaceId: string;

View File

@ -41,6 +41,8 @@ export class MetadataController {
entities.push(...dataSourceEntities);
}
this.dataSourceService.createWorkspaceSchema(workspace.id);
await this.migrationGenerator.executeMigrationFromPendingMigrations(
workspace.id,
);

View File

@ -58,11 +58,6 @@ export class MigrationGeneratorService {
);
});
await queryRunner.release();
// We want to destroy all connections to the workspace data source and invalidate the cache
// so that the next request will create a new connection and get the latest entities
await this.dataSourceService.disconnectFromWorkspaceDataSource(workspaceId);
return flattenedPendingMigrations;
}

View File

@ -0,0 +1,61 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AlterFieldMetadataTable1695717691800
implements MigrationInterface
{
name = 'AlterFieldMetadataTable1695717691800';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."field_metadata" ADD "enums" text array`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."field_metadata" ADD "is_nullable" boolean DEFAULT true`,
);
await queryRunner.query(
`ALTER TYPE "metadata"."data_source_metadata_type_enum" RENAME TO "data_source_metadata_type_enum_old"`,
);
await queryRunner.query(
`CREATE TYPE "metadata"."data_source_metadata_type_enum" AS ENUM('postgres')`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."data_source_metadata" ALTER COLUMN "type" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."data_source_metadata" ALTER COLUMN "type" TYPE "metadata"."data_source_metadata_type_enum" USING "type"::"text"::"metadata"."data_source_metadata_type_enum"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."data_source_metadata" ALTER COLUMN "type" SET DEFAULT 'postgres'`,
);
await queryRunner.query(
`DROP TYPE "metadata"."data_source_metadata_type_enum_old"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "metadata"."data_source_metadata_type_enum_old" AS ENUM('postgres', 'mysql')`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."data_source_metadata" ALTER COLUMN "type" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."data_source_metadata" ALTER COLUMN "type" TYPE "metadata"."data_source_metadata_type_enum_old" USING "type"::"text"::"metadata"."data_source_metadata_type_enum_old"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."data_source_metadata" ALTER COLUMN "type" SET DEFAULT 'postgres'`,
);
await queryRunner.query(
`DROP TYPE "metadata"."data_source_metadata_type_enum"`,
);
await queryRunner.query(
`ALTER TYPE "metadata"."data_source_metadata_type_enum_old" RENAME TO "data_source_metadata_type_enum"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."field_metadata" DROP COLUMN "is_nullable"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."field_metadata" DROP COLUMN "enums"`,
);
}
}

View File

@ -1,7 +1,9 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TenantMigrationService } from './tenant-migration.service';
import { DataSourceService } from 'src/tenant/metadata/data-source/data-source.service';
import { TenantMigrationService } from './tenant-migration.service';
describe('TenantMigrationService', () => {
let service: TenantMigrationService;

View File

@ -0,0 +1,24 @@
import { GraphQLList, GraphQLNonNull, GraphQLObjectType } from 'graphql';
import { PageInfoType } from './page-info.graphql-type';
/**
* 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,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,99 @@
import {
GraphQLBoolean,
GraphQLEnumType,
GraphQLID,
GraphQLInt,
GraphQLNonNull,
GraphQLObjectType,
GraphQLString,
} from 'graphql';
import { FieldMetadata } from 'src/tenant/metadata/field-metadata/field-metadata.entity';
import { ObjectMetadata } from 'src/tenant/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.
*/
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 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

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

View File

@ -0,0 +1,21 @@
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/tenant/metadata/data-source-metadata/data-source-metadata.module';
import { EntitySchemaGeneratorModule } from 'src/tenant/metadata/entity-schema-generator/entity-schema-generator.module';
import { ObjectMetadataModule } from 'src/tenant/metadata/object-metadata/object-metadata.module';
import { SchemaGenerationService } from './schema-generation.service';
@Module({
imports: [
EntityResolverModule,
DataSourceMetadataModule,
EntitySchemaGeneratorModule,
ObjectMetadataModule,
],
providers: [SchemaGenerationService, JwtAuthGuard],
exports: [SchemaGenerationService],
})
export class SchemaGenerationModule {}

View File

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

View File

@ -0,0 +1,150 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import {
GraphQLID,
GraphQLNonNull,
GraphQLObjectType,
GraphQLResolveInfo,
GraphQLSchema,
} from 'graphql';
import { EntityResolverService } from 'src/tenant/entity-resolver/entity-resolver.service';
import { DataSourceMetadataService } from 'src/tenant/metadata/data-source-metadata/data-source-metadata.service';
import { pascalCase } from 'src/utils/pascal-case';
import { ObjectMetadataService } from 'src/tenant/metadata/object-metadata/object-metadata.service';
import { ObjectMetadata } from 'src/tenant/metadata/object-metadata/object-metadata.entity';
import { generateEdgeType } from './graphql-types/edge.graphql-type';
import { generateConnectionType } from './graphql-types/connection.graphql-type';
import { generateObjectTypes } from './graphql-types/object.graphql-type';
@Injectable()
export class SchemaGenerationService {
constructor(
private readonly dataSourceMetadataService: DataSourceMetadataService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly entityResolverService: EntityResolverService,
) {}
private generateQueryFieldForEntity(
entityName: string,
tableName: string,
ObjectType: GraphQLObjectType,
objectDefinition: ObjectMetadata,
workspaceId: string,
) {
const fieldAliases =
objectDefinition?.fields.reduce(
(acc, field) => ({
...acc,
[field.displayName]: field.targetColumnName,
}),
{},
) || {};
const EdgeType = generateEdgeType(ObjectType);
const ConnectionType = generateConnectionType(EdgeType);
return {
[`findAll${pascalCase(entityName)}`]: {
type: ConnectionType,
resolve: async (root, args, context, info: GraphQLResolveInfo) => {
return this.entityResolverService.findAll(
entityName,
tableName,
workspaceId,
info,
fieldAliases,
);
},
},
[`findOne${pascalCase(entityName)}`]: {
type: ObjectType,
args: {
id: { type: new GraphQLNonNull(GraphQLID) },
},
resolve: (root, args, context, info) => {
return this.entityResolverService.findOne(
entityName,
tableName,
args,
workspaceId,
info,
fieldAliases,
);
},
},
};
}
private generateQueryType(
ObjectTypes: Record<string, GraphQLObjectType>,
objectMetadata: ObjectMetadata[],
workspaceId: string,
): GraphQLObjectType {
const fields: any = {};
for (const [entityName, ObjectType] of Object.entries(ObjectTypes)) {
const objectDefinition = objectMetadata.find(
(object) => object.displayName === entityName,
);
const tableName = objectDefinition?.targetTableName ?? '';
if (!objectDefinition) {
throw new InternalServerErrorException('Object definition not found');
}
Object.assign(
fields,
this.generateQueryFieldForEntity(
entityName,
tableName,
ObjectType,
objectDefinition,
workspaceId,
),
);
}
return new GraphQLObjectType({
name: 'Query',
fields,
});
}
async generateSchema(
workspaceId: string | undefined,
): Promise<GraphQLSchema> {
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,
);
const ObjectTypes = generateObjectTypes(objectMetadata);
const QueryType = this.generateQueryType(
ObjectTypes,
objectMetadata,
workspaceId,
);
return new GraphQLSchema({
query: QueryType,
});
}
}

View File

@ -2,8 +2,9 @@ import { Module } from '@nestjs/common';
import { MetadataModule } from './metadata/metadata.module';
import { UniversalModule } from './universal/universal.module';
import { SchemaGenerationModule } from './schema-generation/schema-generation.module';
@Module({
imports: [MetadataModule, UniversalModule],
imports: [MetadataModule, UniversalModule, SchemaGenerationModule],
})
export class TenantModule {}

View File

@ -1,5 +1,5 @@
import { Type } from '@nestjs/common';
import { ArgsType, Directive, Field, ObjectType } from '@nestjs/graphql';
import { ArgsType, Field, ObjectType } from '@nestjs/graphql';
import { IsNumber, IsOptional, IsString } from 'class-validator';
@ -50,7 +50,6 @@ export function Paginated<T>(classRef: Type<T>): Type<IConnection<T>> {
public cursor!: ConnectionCursor;
@Field(() => classRef, { nullable: true })
@Directive(`@cacheControl(inheritMaxAge: true)`)
public node!: T;
}
@ -59,11 +58,9 @@ export function Paginated<T>(classRef: Type<T>): Type<IConnection<T>> {
public name = `${classRef.name}Connection`;
@Field(() => [Edge], { nullable: true })
@Directive(`@cacheControl(inheritMaxAge: true)`)
public edges!: IEdge<T>[];
@Field(() => PageInfo, { nullable: true })
@Directive(`@cacheControl(inheritMaxAge: true)`)
public pageInfo!: IPageInfo;
@Field()

View File

@ -0,0 +1,32 @@
import isObject from 'lodash.isobject';
import lodashCamelCase from 'lodash.camelcase';
import { PascalCase, PascalCasedPropertiesDeep } from 'type-fest';
export const capitalizeFirstLetter = (str: string) => {
return str.charAt(0).toUpperCase() + str.slice(1);
};
export const pascalCase = <T>(text: T) =>
capitalizeFirstLetter(
lodashCamelCase(text as unknown as string),
) as PascalCase<T>;
export const pascalCaseDeep = <T>(value: T): PascalCasedPropertiesDeep<T> => {
// Check if it's an array
if (Array.isArray(value)) {
return value.map(pascalCaseDeep) as PascalCasedPropertiesDeep<T>;
}
// Check if it's an object
if (isObject(value)) {
const result: Record<string, any> = {};
for (const key in value) {
result[pascalCase(key)] = pascalCaseDeep(value[key]);
}
return result as PascalCasedPropertiesDeep<T>;
}
return value as PascalCasedPropertiesDeep<T>;
};