Add a dedicated GQL server for metadata available on /meta (#1820)

This commit is contained in:
Weiko
2023-10-03 10:17:13 +02:00
committed by GitHub
parent 37475f7c1b
commit 1e91c985df
56 changed files with 105 additions and 44 deletions

View File

@ -0,0 +1,21 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@ArgsType()
export class CreateCustomFieldInput {
@Field(() => String)
@IsNotEmpty()
@IsString()
name: string;
@Field(() => String)
@IsNotEmpty()
@IsString()
type: string;
@Field(() => String)
@IsNotEmpty()
@IsString()
objectId: string;
}

View File

@ -0,0 +1,40 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
DataSourceOptions,
} from 'typeorm';
type DataSourceType = DataSourceOptions['type'];
@Entity('data_source_metadata')
export class DataSourceMetadata {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true })
url: string;
@Column({ nullable: true })
schema: string;
@Column({ type: 'enum', enum: ['postgres'], default: 'postgres' })
type: DataSourceType;
@Column({ nullable: true, name: 'display_name' })
displayName: string;
@Column({ default: false, name: 'is_remote' })
isRemote: boolean;
@Column({ nullable: false, name: 'workspace_id' })
workspaceId: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DataSourceMetadataService } from './data-source-metadata.service';
import { DataSourceMetadata } from './data-source-metadata.entity';
@Module({
imports: [TypeOrmModule.forFeature([DataSourceMetadata], 'metadata')],
providers: [DataSourceMetadataService],
exports: [DataSourceMetadataService],
})
export class DataSourceMetadataModule {}

View File

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

View File

@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DataSourceMetadata } from './data-source-metadata.entity';
@Injectable()
export class DataSourceMetadataService {
constructor(
@InjectRepository(DataSourceMetadata, 'metadata')
private readonly dataSourceMetadataRepository: Repository<DataSourceMetadata>,
) {}
async createDataSourceMetadata(workspaceId: string, workspaceSchema: string) {
// TODO: Double check if this is the correct way to do this
const dataSource = await this.dataSourceMetadataRepository.findOne({
where: { workspaceId },
});
if (dataSource) {
return dataSource;
}
return this.dataSourceMetadataRepository.save({
workspaceId,
schema: workspaceSchema,
});
}
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

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { DataSourceMetadataModule } from 'src/metadata/data-source-metadata/data-source-metadata.module';
import { EntitySchemaGeneratorModule } from 'src/metadata/entity-schema-generator/entity-schema-generator.module';
import { DataSourceService } from './data-source.service';
@Module({
imports: [DataSourceMetadataModule, EntitySchemaGeneratorModule],
providers: [DataSourceService],
exports: [DataSourceService],
})
export class DataSourceModule {}

View File

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

View File

@ -0,0 +1,177 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { DataSource, QueryRunner, Table } from 'typeorm';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { DataSourceMetadataService } from 'src/metadata/data-source-metadata/data-source-metadata.service';
import { EntitySchemaGeneratorService } from 'src/metadata/entity-schema-generator/entity-schema-generator.service';
import { TenantMigration } from 'src/metadata/tenant-migration/tenant-migration.entity';
import { uuidToBase36 } from './data-source.util';
@Injectable()
export class DataSourceService implements OnModuleInit, OnModuleDestroy {
private mainDataSource: DataSource;
private dataSources: Map<string, DataSource> = new Map();
constructor(
private readonly environmentService: EnvironmentService,
private readonly dataSourceMetadataService: DataSourceMetadataService,
private readonly entitySchemaGeneratorService: EntitySchemaGeneratorService,
) {
this.mainDataSource = new DataSource({
url: environmentService.getPGDatabaseUrl(),
type: 'postgres',
logging: false,
schema: 'public',
});
}
/**
* Creates a new schema for a given workspaceId
* @param workspaceId
* @returns 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) {
return schemaName;
}
await queryRunner.createSchema(schemaName, true);
await this.createMigrationTable(queryRunner, schemaName);
await queryRunner.release();
await this.dataSourceMetadataService.createDataSourceMetadata(
workspaceId,
schemaName,
);
return schemaName;
}
private async createMigrationTable(
queryRunner: QueryRunner,
schemaName: string,
) {
await queryRunner.createTable(
new Table({
name: 'tenant_migrations',
schema: schemaName,
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
default: 'uuid_generate_v4()',
},
{
name: 'migrations',
type: 'jsonb',
},
{
name: 'applied_at',
type: 'timestamp',
isNullable: true,
},
{
name: 'created_at',
type: 'timestamp',
default: 'now()',
},
],
}),
);
}
/**
* Connects to a workspace data source using the workspace metadata. Returns a cached connection if it exists.
* @param workspaceId
* @returns Promise<DataSource | undefined>
*/
public async connectToWorkspaceDataSource(
workspaceId: string,
): Promise<DataSource | undefined> {
if (this.dataSources.has(workspaceId)) {
const cachedDataSource = this.dataSources.get(workspaceId);
return cachedDataSource;
}
// 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 =
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
if (!schema && !dataSourceMetadata.isRemote) {
throw Error(
"No schema found for this non-remote data source, we don't want to fallback to public for workspace data sources.",
);
}
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(),
type: 'postgres',
logging: ['query'],
schema,
entities: {
TenantMigration,
},
});
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 workspaceDataSource;
}
/**
* Disconnects from a workspace data source.
* @param workspaceId
* @returns Promise<void>
*
*/
public async disconnectFromWorkspaceDataSource(workspaceId: string) {
if (!this.dataSources.has(workspaceId)) {
return;
}
const dataSource = this.dataSources.get(workspaceId);
await dataSource?.destroy();
this.dataSources.delete(workspaceId);
}
/**
*
* Returns the schema name for a given workspaceId
* @param workspaceId
* @returns string
*/
public getSchemaName(workspaceId: string): string {
return `workspace_${uuidToBase36(workspaceId)}`;
}
async onModuleInit() {
// Init main data source "default" schema
await this.mainDataSource.initialize();
}
async onModuleDestroy() {
// Destroy main data source "default" schema
await this.mainDataSource.destroy();
}
}

View File

@ -0,0 +1,21 @@
/**
* Converts a UUID to a base 36 string.
* This is used to generate the schema name since hyphens from workspace uuid are not allowed in postgres schema names.
*
* @param uuid
* @returns
*/
export function uuidToBase36(uuid: string): string {
let devId = false;
if (uuid.startsWith('twenty-')) {
devId = true;
// Clean dev uuids (twenty-)
uuid = uuid.replace('twenty-', '');
}
const hexString = uuid.replace(/-/g, '');
const base10Number = BigInt('0x' + hexString);
const base36String = base10Number.toString(36);
return `${devId ? 'twenty_' : ''}${base36String}`;
}

View File

@ -0,0 +1,15 @@
export const baseColumns = {
id: {
primary: true,
type: 'uuid',
generated: 'uuid',
},
createdAt: {
type: 'timestamp',
createDate: true,
},
updatedAt: {
type: 'timestamp',
updateDate: true,
},
} as const;

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module';
import { EntitySchemaGeneratorService } from './entity-schema-generator.service';
@Module({
imports: [ObjectMetadataModule],
providers: [EntitySchemaGeneratorService],
exports: [EntitySchemaGeneratorService],
})
export class EntitySchemaGeneratorModule {}

View File

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

View File

@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { EntitySchema } from 'typeorm';
import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service';
import { baseColumns } from './base.entity';
import {
convertFieldTypeToPostgresType,
sanitizeColumnName,
} from './entity-schema-generator.util';
@Injectable()
export class EntitySchemaGeneratorService {
constructor(private readonly objectMetadataService: ObjectMetadataService) {}
async getTypeORMEntitiesByDataSourceId(dataSourceId: string) {
const objectMetadata =
await this.objectMetadataService.getObjectMetadataFromDataSourceId(
dataSourceId,
);
const entities = objectMetadata.map((object) => {
return new EntitySchema({
name: object.targetTableName,
columns: {
...baseColumns,
...object.fields.reduce((columns, field) => {
return {
...columns,
[sanitizeColumnName(field.targetColumnName)]: {
type: convertFieldTypeToPostgresType(field.type),
nullable: true,
},
};
}, {}),
},
});
});
return entities;
}
}

View File

@ -0,0 +1,45 @@
/**
* Converts a UUID to a base 36 string.
* This is used to generate the schema name since hyphens from workspace uuid are not allowed in postgres schema names.
*
* @param uuid
* @returns
*/
export const uuidToBase36 = (uuid: string): string => {
const hexString = uuid.replace(/-/g, '');
const base10Number = BigInt('0x' + hexString);
const base36String = base10Number.toString(36);
return base36String;
};
/**
* Sanitizes a column name by replacing all non-alphanumeric characters with an underscore.
* Note: Probablay not the best way to do this, leaving it here as a placeholder for now.
*
* @param columnName
* @returns string
*/
export const sanitizeColumnName = (columnName: string): string =>
columnName.replace(/[^a-zA-Z0-9]/g, '_');
/**
* Converts a field type to a postgres type. Field types are defined in the UI.
*
* @param fieldType
* @returns string
*/
export const convertFieldTypeToPostgresType = (fieldType: string): string => {
switch (fieldType) {
case 'text':
case 'url':
return 'text';
case 'number':
return 'numeric';
case 'boolean':
return 'boolean';
case 'date':
return 'timestamp';
default:
return 'text';
}
};

View File

@ -0,0 +1,51 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { ObjectMetadata } from 'src/metadata/object-metadata/object-metadata.entity';
@Entity('field_metadata')
export class FieldMetadata {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, name: 'object_id' })
objectId: string;
@Column({ nullable: false })
type: string;
@Column({ nullable: false, name: 'display_name' })
displayName: string;
@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;
@ManyToOne(() => ObjectMetadata, (object) => object.fields)
@JoinColumn({ name: 'object_id' })
object: ObjectMetadata;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FieldMetadataService } from './field-metadata.service';
import { FieldMetadata } from './field-metadata.entity';
@Module({
imports: [TypeOrmModule.forFeature([FieldMetadata], 'metadata')],
providers: [FieldMetadataService],
exports: [FieldMetadataService],
})
export class FieldMetadataModule {}

View File

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

View File

@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FieldMetadata } from './field-metadata.entity';
import { generateColumnName } from './field-metadata.util';
@Injectable()
export class FieldMetadataService {
constructor(
@InjectRepository(FieldMetadata, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadata>,
) {}
public async createFieldMetadata(
name: string,
type: string,
objectId: string,
workspaceId: string,
): Promise<FieldMetadata> {
return await this.fieldMetadataRepository.save({
displayName: name,
type,
objectId,
isCustom: true,
targetColumnName: generateColumnName(name),
workspaceId,
});
}
public async getFieldMetadataByNameAndObjectId(
name: string,
objectId: string,
): Promise<FieldMetadata | null> {
return await this.fieldMetadataRepository.findOne({
where: { displayName: name, objectId },
});
}
}

View File

@ -0,0 +1,9 @@
/**
* Generate a column name from a field name removing unsupported characters.
*
* @param name string
* @returns string
*/
export function generateColumnName(name: string): string {
return name.toLowerCase().replace(/ /g, '_');
}

View File

@ -0,0 +1,42 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MetadataController } from './metadata.controller';
import { DataSourceService } from './data-source/data-source.service';
import { DataSourceMetadataService } from './data-source-metadata/data-source-metadata.service';
import { EntitySchemaGeneratorService } from './entity-schema-generator/entity-schema-generator.service';
import { MigrationGeneratorService } from './migration-generator/migration-generator.service';
describe('MetadataController', () => {
let controller: MetadataController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [MetadataController],
providers: [
{
provide: DataSourceService,
useValue: {},
},
{
provide: DataSourceMetadataService,
useValue: {},
},
{
provide: EntitySchemaGeneratorService,
useValue: {},
},
{
provide: MigrationGeneratorService,
useValue: {},
},
],
}).compile();
controller = module.get<MetadataController>(MetadataController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,54 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Workspace } from '@prisma/client';
import { EntitySchema } from 'typeorm';
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { DataSourceMetadataService } from './data-source-metadata/data-source-metadata.service';
import { EntitySchemaGeneratorService } from './entity-schema-generator/entity-schema-generator.service';
import { DataSourceService } from './data-source/data-source.service';
import { MigrationGeneratorService } from './migration-generator/migration-generator.service';
@UseGuards(JwtAuthGuard)
@Controller('metadata_legacy')
export class MetadataController {
constructor(
private readonly entitySchemaGeneratorService: EntitySchemaGeneratorService,
private readonly dataSourceMetadataService: DataSourceMetadataService,
private readonly dataSourceService: DataSourceService,
private readonly migrationGenerator: MigrationGeneratorService,
) {}
@Get()
async getMetadata(@AuthWorkspace() workspace: Workspace) {
const dataSourcesMetadata =
await this.dataSourceMetadataService.getDataSourcesMetadataFromWorkspaceId(
workspace.id,
);
const entities: EntitySchema<{
id: unknown;
}>[] = [];
for (const dataSource of dataSourcesMetadata) {
const dataSourceEntities =
await this.entitySchemaGeneratorService.getTypeORMEntitiesByDataSourceId(
dataSource.id,
);
entities.push(...dataSourceEntities);
}
this.dataSourceService.createWorkspaceSchema(workspace.id);
await this.migrationGenerator.executeMigrationFromPendingMigrations(
workspace.id,
);
this.dataSourceService.connectToWorkspaceDataSource(workspace.id);
return entities;
}
}

View File

@ -0,0 +1,25 @@
import { ConfigService } from '@nestjs/config';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { DataSource, DataSourceOptions } from 'typeorm';
import { config } from 'dotenv';
config();
const configService = new ConfigService();
export const typeORMMetadataModuleOptions: TypeOrmModuleOptions = {
url: configService.get<string>('PG_DATABASE_URL'),
type: 'postgres',
logging: false,
schema: 'metadata',
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: false,
migrationsRun: true,
migrationsTableName: '_typeorm_migrations',
migrations: [__dirname + '/migrations/**/*{.ts,.js}'],
};
export const connectionSource = new DataSource(
typeORMMetadataModuleOptions as DataSourceOptions,
);

View File

@ -0,0 +1,53 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
import { GraphQLModule } from '@nestjs/graphql';
import { YogaDriverConfig, YogaDriver } from '@graphql-yoga/nestjs';
import GraphQLJSON from 'graphql-type-json';
import { MetadataService } from './metadata.service';
import { MetadataController } from './metadata.controller';
import { typeORMMetadataModuleOptions } from './metadata.datasource';
import { MetadataResolver } from './metadata.resolver';
import { DataSourceModule } from './data-source/data-source.module';
import { DataSourceMetadataModule } from './data-source-metadata/data-source-metadata.module';
import { FieldMetadataModule } from './field-metadata/field-metadata.module';
import { ObjectMetadataModule } from './object-metadata/object-metadata.module';
import { EntitySchemaGeneratorModule } from './entity-schema-generator/entity-schema-generator.module';
import { MigrationGeneratorModule } from './migration-generator/migration-generator.module';
import { TenantMigrationModule } from './tenant-migration/tenant-migration.module';
const typeORMFactory = async (): Promise<TypeOrmModuleOptions> => ({
...typeORMMetadataModuleOptions,
name: 'metadata',
});
@Module({
imports: [
TypeOrmModule.forRootAsync({
useFactory: typeORMFactory,
name: 'metadata',
}),
GraphQLModule.forRoot<YogaDriverConfig>({
context: ({ req }) => ({ req }),
driver: YogaDriver,
autoSchemaFile: true,
include: [MetadataModule],
resolvers: { JSON: GraphQLJSON },
plugins: [],
path: '/metadata',
}),
DataSourceModule,
DataSourceMetadataModule,
FieldMetadataModule,
ObjectMetadataModule,
EntitySchemaGeneratorModule,
MigrationGeneratorModule,
TenantMigrationModule,
],
providers: [MetadataService, MetadataResolver],
exports: [MetadataService],
controllers: [MetadataController],
})
export class MetadataModule {}

View File

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

View File

@ -0,0 +1,35 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Workspace } from '@prisma/client';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
import { MetadataService } from './metadata.service';
import { CreateCustomFieldInput } from './args/create-custom-field.input';
@UseGuards(JwtAuthGuard)
@Resolver()
export class MetadataResolver {
constructor(private readonly metadataService: MetadataService) {}
@Query(() => String)
async hello(): Promise<string> {
return 'Hello World!';
}
@Mutation(() => String)
async createCustomField(
@Args() createCustomFieldInput: CreateCustomFieldInput,
@AuthWorkspace() workspace: Workspace,
): Promise<string> {
return this.metadataService.createCustomField(
createCustomFieldInput.name,
createCustomFieldInput.objectId,
createCustomFieldInput.type,
workspace.id,
);
}
}

View File

@ -0,0 +1,47 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MetadataService } from './metadata.service';
import { MigrationGeneratorService } from './migration-generator/migration-generator.service';
import { DataSourceService } from './data-source/data-source.service';
import { ObjectMetadataService } from './object-metadata/object-metadata.service';
import { TenantMigrationService } from './tenant-migration/tenant-migration.service';
import { FieldMetadataService } from './field-metadata/field-metadata.service';
describe('MetadataService', () => {
let service: MetadataService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MetadataService,
{
provide: DataSourceService,
useValue: {},
},
{
provide: MigrationGeneratorService,
useValue: {},
},
{
provide: ObjectMetadataService,
useValue: {},
},
{
provide: TenantMigrationService,
useValue: {},
},
{
provide: FieldMetadataService,
useValue: {},
},
],
}).compile();
service = module.get<MetadataService>(MetadataService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,99 @@
import { Injectable } from '@nestjs/common';
import { DataSourceService } from './data-source/data-source.service';
import { FieldMetadataService } from './field-metadata/field-metadata.service';
import { MigrationGeneratorService } from './migration-generator/migration-generator.service';
import { ObjectMetadataService } from './object-metadata/object-metadata.service';
import { TenantMigrationService } from './tenant-migration/tenant-migration.service';
import {
TenantMigrationColumnChange,
TenantMigrationTableChange,
} from './tenant-migration/tenant-migration.entity';
@Injectable()
export class MetadataService {
constructor(
private readonly dataSourceService: DataSourceService,
private readonly migrationGenerator: MigrationGeneratorService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly tenantMigrationService: TenantMigrationService,
private readonly fieldMetadataService: FieldMetadataService,
) {}
public async createCustomField(
name: string,
objectId: string,
type: string,
workspaceId: string,
): Promise<string> {
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
if (!workspaceDataSource) {
throw new Error('Workspace data source not found');
}
const objectMetadata =
await this.objectMetadataService.getObjectMetadataFromId(objectId);
if (!objectMetadata) {
throw new Error('Object not found');
}
const fieldMetadataAlreadyExists =
await this.fieldMetadataService.getFieldMetadataByNameAndObjectId(
name,
objectId,
);
if (fieldMetadataAlreadyExists) {
throw new Error('Field already exists');
}
const createdFieldMetadata =
await this.fieldMetadataService.createFieldMetadata(
name,
type,
objectMetadata.id,
workspaceId,
);
await this.tenantMigrationService.createMigration(workspaceId, [
{
name: objectMetadata.targetTableName,
change: 'alter',
columns: [
{
name: createdFieldMetadata.targetColumnName,
type: this.convertMetadataTypeToColumnType(type),
change: 'create',
} satisfies TenantMigrationColumnChange,
],
} satisfies TenantMigrationTableChange,
]);
await this.migrationGenerator.executeMigrationFromPendingMigrations(
workspaceId,
);
return createdFieldMetadata.id;
}
private convertMetadataTypeToColumnType(type: string) {
switch (type) {
case 'text':
case 'url':
case 'phone':
case 'email':
return 'text';
case 'number':
return 'int';
case 'boolean':
return 'boolean';
case 'date':
return 'timestamp';
default:
throw new Error('Invalid type');
}
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
import { TenantMigrationModule } from 'src/metadata/tenant-migration/tenant-migration.module';
import { MigrationGeneratorService } from './migration-generator.service';
@Module({
imports: [DataSourceModule, TenantMigrationModule],
exports: [MigrationGeneratorService],
providers: [MigrationGeneratorService],
})
export class MigrationGeneratorModule {}

View File

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

View File

@ -0,0 +1,193 @@
import { Injectable } from '@nestjs/common';
import { QueryRunner, Table, TableColumn } from 'typeorm';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import {
TenantMigrationTableChange,
TenantMigrationColumnChange,
} from 'src/metadata/tenant-migration/tenant-migration.entity';
import { TenantMigrationService } from 'src/metadata/tenant-migration/tenant-migration.service';
@Injectable()
export class MigrationGeneratorService {
constructor(
private readonly dataSourceService: DataSourceService,
private readonly tenantMigrationService: TenantMigrationService,
) {}
/**
* Executes pending migrations for a given workspace
*
* @param workspaceId string
* @returns Promise<TenantMigrationTableChange[]>
*/
public async executeMigrationFromPendingMigrations(
workspaceId: string,
): Promise<TenantMigrationTableChange[]> {
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
if (!workspaceDataSource) {
throw new Error('Workspace data source not found');
}
const pendingMigrations =
await this.tenantMigrationService.getPendingMigrations(workspaceId);
const flattenedPendingMigrations: TenantMigrationTableChange[] =
pendingMigrations.reduce((acc, pendingMigration) => {
return [...acc, ...pendingMigration.migrations];
}, []);
const queryRunner = workspaceDataSource?.createQueryRunner();
const schemaName = this.dataSourceService.getSchemaName(workspaceId);
// Loop over each migration and create or update the table
// TODO: Should be done in a transaction
flattenedPendingMigrations.forEach(async (migration) => {
await this.handleTableChanges(queryRunner, schemaName, migration);
});
// Update appliedAt date for each migration
// TODO: Should be done after the migration is successful
pendingMigrations.forEach(async (pendingMigration) => {
await this.tenantMigrationService.setAppliedAtForMigration(
workspaceId,
pendingMigration,
);
});
return flattenedPendingMigrations;
}
/**
* Handles table changes for a given migration
*
* @param queryRunner QueryRunner
* @param schemaName string
* @param tableMigration TenantMigrationTableChange
*/
private async handleTableChanges(
queryRunner: QueryRunner,
schemaName: string,
tableMigration: TenantMigrationTableChange,
) {
switch (tableMigration.change) {
case 'create':
await this.createTable(queryRunner, schemaName, tableMigration.name);
break;
case 'alter':
await this.handleColumnChanges(
queryRunner,
schemaName,
tableMigration.name,
tableMigration?.columns,
);
break;
default:
throw new Error(
`Migration table change ${tableMigration.change} not supported`,
);
}
}
/**
* Creates a table for a given schema and table name
*
* @param queryRunner QueryRunner
* @param schemaName string
* @param tableName string
*/
private async createTable(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
) {
await queryRunner.createTable(
new Table({
name: tableName,
schema: schemaName,
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
default: 'uuid_generate_v4()',
},
{
name: 'created_at',
type: 'timestamp',
default: 'now()',
},
],
}),
true,
);
}
/**
* Handles column changes for a given migration
*
* @param queryRunner QueryRunner
* @param schemaName string
* @param tableName string
* @param columnMigrations TenantMigrationColumnChange[]
* @returns
*/
private async handleColumnChanges(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
columnMigrations?: TenantMigrationColumnChange[],
) {
if (!columnMigrations || columnMigrations.length === 0) {
return;
}
for (const columnMigration of columnMigrations) {
switch (columnMigration.change) {
case 'create':
await this.createColumn(
queryRunner,
schemaName,
tableName,
columnMigration,
);
break;
case 'alter':
throw new Error(
`Migration column change ${columnMigration.change} not supported`,
);
default:
throw new Error(
`Migration column change ${columnMigration.change} not supported`,
);
}
}
}
/**
* Creates a column for a given schema, table name, and column migration
*
* @param queryRunner QueryRunner
* @param schemaName string
* @param tableName string
* @param migrationColumn TenantMigrationColumnChange
*/
private async createColumn(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
migrationColumn: TenantMigrationColumnChange,
) {
await queryRunner.addColumn(
`${schemaName}.${tableName}`,
new TableColumn({
name: migrationColumn.name,
type: migrationColumn.type,
isNullable: true,
}),
);
}
}

View File

@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class InitMetadataTables1695214465080 implements MigrationInterface {
name = 'InitMetadataTables1695214465080';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "metadata"."data_source_metadata_type_enum" AS ENUM ('postgres', 'mysql');`,
);
await queryRunner.query(
`CREATE TABLE "metadata"."data_source_metadata" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "url" character varying, "schema" character varying, "type" "metadata"."data_source_metadata_type_enum" NOT NULL DEFAULT 'postgres', "display_name" character varying, "is_remote" boolean NOT NULL DEFAULT false, "workspace_id" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_923752b7e62a300a4969bd0e038" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "metadata"."field_metadata" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "object_id" uuid NOT NULL, "type" character varying NOT NULL, "display_name" character varying NOT NULL, "target_column_name" character varying NOT NULL, "is_custom" boolean NOT NULL DEFAULT false, "workspace_id" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_c75db587904cad6af109b5c65f1" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "metadata"."object_metadata" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "data_source_id" character varying NOT NULL, "display_name" character varying NOT NULL, "target_table_name" character varying NOT NULL, "is_custom" boolean NOT NULL DEFAULT false, "workspace_id" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_c8c5f885767b356949c18c201c1" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."field_metadata" ADD CONSTRAINT "FK_38179b299795e48887fc99f937a" FOREIGN KEY ("object_id") REFERENCES "metadata"."object_metadata"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."field_metadata" DROP CONSTRAINT "FK_38179b299795e48887fc99f937a"`,
);
await queryRunner.query(`DROP TABLE "metadata"."object_metadata"`);
await queryRunner.query(`DROP TABLE "metadata"."field_metadata"`);
await queryRunner.query(`DROP TABLE "metadata"."data_source_metadata"`);
}
}

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

@ -0,0 +1,40 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
@Entity('object_metadata')
export class ObjectMetadata {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, name: 'data_source_id' })
dataSourceId: string;
@Column({ nullable: false, name: 'display_name' })
displayName: string;
@Column({ nullable: false, name: 'target_table_name' })
targetTableName: string;
@Column({ default: false, name: 'is_custom' })
isCustom: boolean;
@Column({ nullable: false, name: 'workspace_id' })
workspaceId: string;
@OneToMany(() => FieldMetadata, (field) => field.object)
fields: FieldMetadata[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ObjectMetadataService } from './object-metadata.service';
import { ObjectMetadata } from './object-metadata.entity';
@Module({
imports: [TypeOrmModule.forFeature([ObjectMetadata], 'metadata')],
providers: [ObjectMetadataService],
exports: [ObjectMetadataService],
})
export class ObjectMetadataModule {}

View File

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

View File

@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ObjectMetadata } from './object-metadata.entity';
@Injectable()
export class ObjectMetadataService {
constructor(
@InjectRepository(ObjectMetadata, 'metadata')
private readonly fieldMetadataRepository: Repository<ObjectMetadata>,
) {}
public async getObjectMetadataFromDataSourceId(dataSourceId: string) {
return this.fieldMetadataRepository.find({
where: { dataSourceId },
relations: ['fields'],
});
}
public async getObjectMetadataFromId(objectMetadataId: string) {
return this.fieldMetadataRepository.findOne({
where: { id: objectMetadataId },
relations: ['fields'],
});
}
}

View File

@ -0,0 +1,33 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
export type TenantMigrationColumnChange = {
name: string;
type: string;
change: 'create' | 'alter';
};
export type TenantMigrationTableChange = {
name: string;
change: 'create' | 'alter';
columns?: TenantMigrationColumnChange[];
};
@Entity('tenant_migrations')
export class TenantMigration {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true, type: 'jsonb' })
migrations: TenantMigrationTableChange[];
@Column({ nullable: true, name: 'applied_at' })
appliedAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}

View File

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

View File

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

View File

@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import {
TenantMigration,
TenantMigrationTableChange,
} from './tenant-migration.entity';
@Injectable()
export class TenantMigrationService {
constructor(private readonly dataSourceService: DataSourceService) {}
/**
* Get all pending migrations for a given workspaceId
*
* @param workspaceId: string
* @returns Promise<TenantMigration[]>
*/
public async getPendingMigrations(
workspaceId: string,
): Promise<TenantMigration[]> {
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
if (!workspaceDataSource) {
throw new Error('Workspace data source not found');
}
const tenantMigrationRepository =
workspaceDataSource.getRepository(TenantMigration);
return tenantMigrationRepository.find({
order: { createdAt: 'ASC' },
where: { appliedAt: IsNull() },
});
}
/**
* Set appliedAt as current date for a given migration.
* Should be called once the migration has been applied
*
* @param workspaceId: string
* @param migration: TenantMigration
*/
public async setAppliedAtForMigration(
workspaceId: string,
migration: TenantMigration,
) {
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
if (!workspaceDataSource) {
throw new Error('Workspace data source not found');
}
const tenantMigrationRepository =
workspaceDataSource.getRepository(TenantMigration);
await tenantMigrationRepository.save({
id: migration.id,
appliedAt: new Date(),
});
}
/**
* Create a new pending migration for a given workspaceId and expected changes
*
* @param workspaceId
* @param migrations
*/
public async createMigration(
workspaceId: string,
migrations: TenantMigrationTableChange[],
) {
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
if (!workspaceDataSource) {
throw new Error('Workspace data source not found');
}
const tenantMigrationRepository =
workspaceDataSource.getRepository(TenantMigration);
await tenantMigrationRepository.save({
migrations,
});
}
}