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:
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
12
server/src/tenant/entity-resolver/entity-resolver.module.ts
Normal file
12
server/src/tenant/entity-resolver/entity-resolver.module.ts
Normal 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 {}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
111
server/src/tenant/entity-resolver/entity-resolver.service.ts
Normal file
111
server/src/tenant/entity-resolver/entity-resolver.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
23
server/src/tenant/entity-resolver/entity-resolver.util.ts
Normal file
23
server/src/tenant/entity-resolver/entity-resolver.util.ts
Normal 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;
|
||||
};
|
||||
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -41,6 +41,8 @@ export class MetadataController {
|
||||
entities.push(...dataSourceEntities);
|
||||
}
|
||||
|
||||
this.dataSourceService.createWorkspaceSchema(workspace.id);
|
||||
|
||||
await this.migrationGenerator.executeMigrationFromPendingMigrations(
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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,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;
|
||||
};
|
||||
@ -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) },
|
||||
},
|
||||
});
|
||||
@ -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 {}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
150
server/src/tenant/schema-generation/schema-generation.service.ts
Normal file
150
server/src/tenant/schema-generation/schema-generation.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
@ -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()
|
||||
|
||||
32
server/src/utils/pascal-case.ts
Normal file
32
server/src/utils/pascal-case.ts
Normal 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>;
|
||||
};
|
||||
Reference in New Issue
Block a user