Add a dedicated GQL server for metadata available on /meta (#1820)
This commit is contained in:
21
server/src/metadata/args/create-custom-field.input.ts
Normal file
21
server/src/metadata/args/create-custom-field.input.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
13
server/src/metadata/data-source/data-source.module.ts
Normal file
13
server/src/metadata/data-source/data-source.module.ts
Normal 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 {}
|
||||
39
server/src/metadata/data-source/data-source.service.spec.ts
Normal file
39
server/src/metadata/data-source/data-source.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
177
server/src/metadata/data-source/data-source.service.ts
Normal file
177
server/src/metadata/data-source/data-source.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
21
server/src/metadata/data-source/data-source.util.ts
Normal file
21
server/src/metadata/data-source/data-source.util.ts
Normal 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}`;
|
||||
}
|
||||
15
server/src/metadata/entity-schema-generator/base.entity.ts
Normal file
15
server/src/metadata/entity-schema-generator/base.entity.ts
Normal 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;
|
||||
@ -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 {}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
};
|
||||
51
server/src/metadata/field-metadata/field-metadata.entity.ts
Normal file
51
server/src/metadata/field-metadata/field-metadata.entity.ts
Normal 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;
|
||||
}
|
||||
12
server/src/metadata/field-metadata/field-metadata.module.ts
Normal file
12
server/src/metadata/field-metadata/field-metadata.module.ts
Normal 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 {}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
40
server/src/metadata/field-metadata/field-metadata.service.ts
Normal file
40
server/src/metadata/field-metadata/field-metadata.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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, '_');
|
||||
}
|
||||
42
server/src/metadata/metadata.controller.spec.ts
Normal file
42
server/src/metadata/metadata.controller.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
54
server/src/metadata/metadata.controller.ts
Normal file
54
server/src/metadata/metadata.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
25
server/src/metadata/metadata.datasource.ts
Normal file
25
server/src/metadata/metadata.datasource.ts
Normal 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,
|
||||
);
|
||||
53
server/src/metadata/metadata.module.ts
Normal file
53
server/src/metadata/metadata.module.ts
Normal 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 {}
|
||||
26
server/src/metadata/metadata.resolver.spec.ts
Normal file
26
server/src/metadata/metadata.resolver.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
35
server/src/metadata/metadata.resolver.ts
Normal file
35
server/src/metadata/metadata.resolver.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
47
server/src/metadata/metadata.service.spec.ts
Normal file
47
server/src/metadata/metadata.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
99
server/src/metadata/metadata.service.ts
Normal file
99
server/src/metadata/metadata.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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"`);
|
||||
}
|
||||
}
|
||||
@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user