feat: implement Database Manager module with encryption, CRUD operations, and migration management
This commit is contained in:
@ -7,3 +7,6 @@ DB_DATABASE=low_code_engine
|
|||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
PORT=3054
|
||||||
|
ENCRYPTION_KEY=12345678901234567890123456789012
|
||||||
|
IV_LENGTH=16
|
||||||
@ -37,6 +37,7 @@
|
|||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"@types/ioredis": "^5.0.0",
|
"@types/ioredis": "^5.0.0",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
|
"crypto": "^1.0.1",
|
||||||
"ioredis": "^5.7.0",
|
"ioredis": "^5.7.0",
|
||||||
"isolated-vm": "^6.0.1",
|
"isolated-vm": "^6.0.1",
|
||||||
"mariadb": "^3.4.5",
|
"mariadb": "^3.4.5",
|
||||||
|
|||||||
@ -9,8 +9,8 @@ export class ApiController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post("token/generate")
|
@Post("token/generate")
|
||||||
generateToken(@Body() body: { token: string }) {
|
generateToken(@Body() body: { id: string }) {
|
||||||
return this.apiService.generateToken(body.token);
|
return this.apiService.generateToken(body.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete("token/revoke")
|
@Delete("token/revoke")
|
||||||
|
|||||||
@ -14,9 +14,9 @@ export class ApiService {
|
|||||||
private readonly projectRepository: Repository<Project>
|
private readonly projectRepository: Repository<Project>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generateToken(projectToken: string) {
|
async generateToken(projectId: string) {
|
||||||
const project = await this.projectRepository.findOne({
|
const project = await this.projectRepository.findOne({
|
||||||
where: { token: projectToken },
|
where: { id: projectId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
|
|||||||
@ -5,17 +5,17 @@ import { QueryModule } from "src/query/query.module";
|
|||||||
import { ProjectModule } from "src/project/project.module";
|
import { ProjectModule } from "src/project/project.module";
|
||||||
import { RedisModule } from "src/redis/redis.module";
|
import { RedisModule } from "src/redis/redis.module";
|
||||||
import { DatabaseModule } from "src/database/database.module";
|
import { DatabaseModule } from "src/database/database.module";
|
||||||
import { MigrationModule } from "src/migration/migration.module";
|
import { DatabaseManagerModule } from "src/databaseManager/database/database.manager.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
|
DatabaseManagerModule,
|
||||||
ApiModule,
|
ApiModule,
|
||||||
ProjectModule,
|
ProjectModule,
|
||||||
QueryModule,
|
QueryModule,
|
||||||
TestModule,
|
TestModule,
|
||||||
MigrationModule,
|
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
|||||||
31
src/databaseManager/database.encryption.service.ts
Normal file
31
src/databaseManager/database.encryption.service.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import * as crypto from "crypto";
|
||||||
|
|
||||||
|
export class DatabaseEncryptionService {
|
||||||
|
protected encryptPassword(password: string): string {
|
||||||
|
const iv = crypto.randomBytes(
|
||||||
|
process.env.IV_LENGTH ? parseInt(process.env.IV_LENGTH) : 16
|
||||||
|
);
|
||||||
|
const cipher = crypto.createCipheriv(
|
||||||
|
"aes-256-cbc",
|
||||||
|
process.env.ENCRYPTION_KEY,
|
||||||
|
iv
|
||||||
|
);
|
||||||
|
let encrypted = cipher.update(password, "utf8", "base64");
|
||||||
|
encrypted += cipher.final("base64");
|
||||||
|
return iv.toString("base64") + ":" + encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected decryptPassword(encrypted: string): string {
|
||||||
|
const parts = encrypted.split(":");
|
||||||
|
const iv = Buffer.from(parts[0], "base64");
|
||||||
|
const encryptedText = parts[1];
|
||||||
|
const decipher = crypto.createDecipheriv(
|
||||||
|
"aes-256-cbc",
|
||||||
|
process.env.ENCRYPTION_KEY,
|
||||||
|
iv
|
||||||
|
);
|
||||||
|
let decrypted = decipher.update(encryptedText, "base64", "utf8");
|
||||||
|
decrypted += decipher.final("utf8");
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/databaseManager/database/database.manager.controller.ts
Normal file
74
src/databaseManager/database/database.manager.controller.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { Controller, Get, Inject, Post } from "@nestjs/common";
|
||||||
|
import { DatabaseManagerService } from "./database.manager.service";
|
||||||
|
import { DatabaseNodeService } from "../databaseNode/database.node.service";
|
||||||
|
import { MigrationService } from "../migration/migration.service";
|
||||||
|
|
||||||
|
@Controller("database")
|
||||||
|
export class DatabaseManagerController {
|
||||||
|
constructor(
|
||||||
|
@Inject("DatabaseService")
|
||||||
|
private readonly databaseManagerService: DatabaseManagerService,
|
||||||
|
@Inject("DatabaseNodeService")
|
||||||
|
private readonly databaseNodeService: DatabaseNodeService,
|
||||||
|
@Inject("MigrationService")
|
||||||
|
private readonly migrationService: MigrationService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post("create")
|
||||||
|
createDatabase(
|
||||||
|
@Inject("body") body: { projectId: string; databaseNodeId: string }
|
||||||
|
) {
|
||||||
|
return this.databaseManagerService.createDatabase(
|
||||||
|
body.projectId,
|
||||||
|
body.databaseNodeId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("node/create")
|
||||||
|
addDatabaseNode(
|
||||||
|
@Inject("body")
|
||||||
|
body: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return this.databaseNodeService.create(
|
||||||
|
body.host,
|
||||||
|
body.port,
|
||||||
|
body.username,
|
||||||
|
body.password
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("migration/up/:databaseId")
|
||||||
|
migrateUp(@Inject("params") params: { databaseId: string }) {
|
||||||
|
return this.migrationService.up(params.databaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("migration/down/:databaseId")
|
||||||
|
migrateDown(@Inject("params") params: { databaseId: string }) {
|
||||||
|
return this.migrationService.down(params.databaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("migration/create")
|
||||||
|
createMigration(
|
||||||
|
@Inject("body")
|
||||||
|
body: {
|
||||||
|
up: string;
|
||||||
|
down: string;
|
||||||
|
databaseId: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return this.migrationService.create(body.up, body.down, body.databaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("query/:databaseId")
|
||||||
|
runQuery(
|
||||||
|
@Inject("params") params: { databaseId: string },
|
||||||
|
@Inject("body") body: { query: string }
|
||||||
|
) {
|
||||||
|
return this.databaseManagerService.runQuery(params.databaseId, body.query);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/databaseManager/database/database.manager.module.ts
Normal file
22
src/databaseManager/database/database.manager.module.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import { Database } from "../entities/database.entity";
|
||||||
|
import { MigrationModule } from "../migration/migration.module";
|
||||||
|
import { ProjectModule } from "src/project/project.module";
|
||||||
|
import { DatabaseManagerController } from "./database.manager.controller";
|
||||||
|
import { DatabaseManagerService } from "./database.manager.service";
|
||||||
|
import { DatabaseNode } from "../entities/database.node.entity";
|
||||||
|
import { Project } from "src/project/entities/project.entity";
|
||||||
|
import { DatabaseNodeService } from "../databaseNode/database.node.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
forwardRef(() => ProjectModule),
|
||||||
|
MigrationModule,
|
||||||
|
TypeOrmModule.forFeature([Database, DatabaseNode, Project]),
|
||||||
|
],
|
||||||
|
controllers: [DatabaseManagerController],
|
||||||
|
providers: [DatabaseManagerService, DatabaseNodeService],
|
||||||
|
exports: [DatabaseManagerService, DatabaseNodeService],
|
||||||
|
})
|
||||||
|
export class DatabaseManagerModule {}
|
||||||
95
src/databaseManager/database/database.manager.service.ts
Normal file
95
src/databaseManager/database/database.manager.service.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Database } from "../entities/database.entity";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { ProjectService } from "src/project/project.service";
|
||||||
|
import { DatabaseEncryptionService } from "../database.encryption.service";
|
||||||
|
import { DatabaseNodeService } from "../databaseNode/database.node.service";
|
||||||
|
import * as mysql from "mysql2/promise";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DatabaseManagerService extends DatabaseEncryptionService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Database)
|
||||||
|
private databaseRepository: Repository<Database>,
|
||||||
|
@Inject(ProjectService)
|
||||||
|
private readonly projectService: ProjectService,
|
||||||
|
@Inject(DatabaseNodeService)
|
||||||
|
private readonly databaseNodeService: DatabaseNodeService
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Database | null> {
|
||||||
|
return this.databaseRepository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async runQuery(databaseId: string, query: string) {
|
||||||
|
const database = await this.findById(databaseId);
|
||||||
|
|
||||||
|
if (!database) {
|
||||||
|
throw new Error("Database not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbConnection = await mysql.createConnection({
|
||||||
|
host: database.node.host,
|
||||||
|
port: database.node.port,
|
||||||
|
user: database.c_username,
|
||||||
|
password: this.decryptPassword(database.password),
|
||||||
|
database: database.database,
|
||||||
|
enableKeepAlive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [results] = await dbConnection.execute(query);
|
||||||
|
return results;
|
||||||
|
} finally {
|
||||||
|
await dbConnection.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDatabase(
|
||||||
|
databaseNodeId: string,
|
||||||
|
projectId: string
|
||||||
|
): Promise<Database> {
|
||||||
|
const node = await this.databaseNodeService.findById(databaseNodeId);
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
throw new Error("Database node not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await this.projectService.findById(projectId);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new Error("Project not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const c_username = `c_user_${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
const q_username = `q_user_${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
const databaseName = `db_${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
const password = this.encryptPassword(
|
||||||
|
Math.random().toString(36).substring(2, 10)
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.databaseNodeService.initDatabase(
|
||||||
|
{
|
||||||
|
database: databaseName,
|
||||||
|
c_username,
|
||||||
|
q_username,
|
||||||
|
password: this.decryptPassword(password),
|
||||||
|
},
|
||||||
|
databaseNodeId
|
||||||
|
);
|
||||||
|
|
||||||
|
const database = this.databaseRepository.create({
|
||||||
|
q_username,
|
||||||
|
c_username,
|
||||||
|
database: databaseName,
|
||||||
|
password,
|
||||||
|
node,
|
||||||
|
project,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.databaseRepository.save(database);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/databaseManager/databaseNode/database.node.service.ts
Normal file
88
src/databaseManager/databaseNode/database.node.service.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { DatabaseNode } from "../entities/database.node.entity";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { DatabaseEncryptionService } from "../database.encryption.service";
|
||||||
|
import * as mysql from "mysql2/promise";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DatabaseNodeService extends DatabaseEncryptionService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(DatabaseNode)
|
||||||
|
private databaseNodeRepository: Repository<DatabaseNode>
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<DatabaseNode | null> {
|
||||||
|
return this.databaseNodeRepository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async initDatabase(
|
||||||
|
data: {
|
||||||
|
database: string;
|
||||||
|
c_username: string;
|
||||||
|
q_username: string;
|
||||||
|
password: string;
|
||||||
|
},
|
||||||
|
databaseNodeId: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dbConnection = await mysql.createConnection({
|
||||||
|
...(await this.getConnectionOptions(databaseNodeId)),
|
||||||
|
enableKeepAlive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await dbConnection.execute(`CREATE DATABASE \`${data.database}\`;`);
|
||||||
|
await dbConnection.execute(
|
||||||
|
`CREATE USER '${data.c_username}'@'%' IDENTIFIED BY '${data.password}';`
|
||||||
|
);
|
||||||
|
await dbConnection.execute(
|
||||||
|
`CREATE USER '${data.q_username}'@'%' IDENTIFIED BY '${data.password}';`
|
||||||
|
);
|
||||||
|
await dbConnection.execute(
|
||||||
|
`GRANT ALL PRIVILEGES ON \`${data.database}\`.* TO '${data.c_username}'@'%';`
|
||||||
|
);
|
||||||
|
await dbConnection.execute(
|
||||||
|
`GRANT SELECT, SHOW VIEW ON \`${data.database}\`.* TO '${data.q_username}'@'%';`
|
||||||
|
);
|
||||||
|
await dbConnection.execute(`FLUSH PRIVILEGES;`);
|
||||||
|
|
||||||
|
await dbConnection.end();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error initializing database:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<DatabaseNode> {
|
||||||
|
const encryptedPassword = this.encryptPassword(password);
|
||||||
|
const databaseNode = this.databaseNodeRepository.create({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password: encryptedPassword,
|
||||||
|
});
|
||||||
|
return this.databaseNodeRepository.save(databaseNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConnectionOptions(id: string) {
|
||||||
|
const node = await this.databaseNodeRepository.findOne({ where: { id } });
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
throw new Error("Database node not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
host: node.host,
|
||||||
|
port: node.port,
|
||||||
|
username: node.username,
|
||||||
|
password: this.decryptPassword(node.password),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/databaseManager/entities/database.entity.ts
Normal file
38
src/databaseManager/entities/database.entity.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
OneToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from "typeorm";
|
||||||
|
import { Migration } from "./migration.entity";
|
||||||
|
import { Project } from "src/project/entities/project.entity";
|
||||||
|
import { DatabaseNode } from "./database.node.entity";
|
||||||
|
|
||||||
|
@Entity("database")
|
||||||
|
export class Database {
|
||||||
|
@PrimaryGeneratedColumn("uuid")
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 255 })
|
||||||
|
q_username: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 255 })
|
||||||
|
c_username: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 255 })
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 255 })
|
||||||
|
database: string;
|
||||||
|
|
||||||
|
@OneToMany(() => Migration, (migration) => migration.database)
|
||||||
|
migrations: Migration[];
|
||||||
|
|
||||||
|
@OneToOne(() => Project, (project) => project.database)
|
||||||
|
project: Project;
|
||||||
|
|
||||||
|
@ManyToOne(() => DatabaseNode, (node) => node.databases)
|
||||||
|
node: DatabaseNode;
|
||||||
|
}
|
||||||
23
src/databaseManager/entities/database.node.entity.ts
Normal file
23
src/databaseManager/entities/database.node.entity.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||||
|
import { Database } from "./database.entity";
|
||||||
|
|
||||||
|
@Entity("databaseNode")
|
||||||
|
export class DatabaseNode {
|
||||||
|
@PrimaryGeneratedColumn("uuid")
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 255 })
|
||||||
|
host: string;
|
||||||
|
|
||||||
|
@Column({ type: "int" })
|
||||||
|
port: number;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 255 })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 255 })
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@OneToMany(() => Database, (database) => database.node)
|
||||||
|
databases: Database[];
|
||||||
|
}
|
||||||
35
src/databaseManager/entities/migration.entity.ts
Normal file
35
src/databaseManager/entities/migration.entity.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from "typeorm";
|
||||||
|
import { Database } from "./database.entity";
|
||||||
|
|
||||||
|
@Entity("migration")
|
||||||
|
export class Migration {
|
||||||
|
@PrimaryGeneratedColumn("uuid")
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" })
|
||||||
|
appliedAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: "tinyint", default: 1 })
|
||||||
|
isApplied: number;
|
||||||
|
|
||||||
|
@Column({ type: "tinyint", default: 1 })
|
||||||
|
isValid: number;
|
||||||
|
|
||||||
|
@Column({ type: "longtext", nullable: false })
|
||||||
|
up: string;
|
||||||
|
|
||||||
|
@Column({ type: "longtext", nullable: false })
|
||||||
|
down: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Database, (database) => database.id)
|
||||||
|
database: Database;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
11
src/databaseManager/migration/migration.module.ts
Normal file
11
src/databaseManager/migration/migration.module.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
|
import { MigrationService } from "./migration.service";
|
||||||
|
import { DatabaseModule } from "src/database/database.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [forwardRef(() => DatabaseModule)],
|
||||||
|
controllers: [],
|
||||||
|
providers: [MigrationService],
|
||||||
|
exports: [MigrationService],
|
||||||
|
})
|
||||||
|
export class MigrationModule {}
|
||||||
95
src/databaseManager/migration/migration.service.ts
Normal file
95
src/databaseManager/migration/migration.service.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { IsNull, Not, Repository } from "typeorm";
|
||||||
|
import { Migration } from "../entities/migration.entity";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { DatabaseManagerService } from "../database/database.manager.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MigrationService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Migration)
|
||||||
|
private readonly migrationRepository: Repository<Migration>,
|
||||||
|
@Inject(DatabaseManagerService)
|
||||||
|
private readonly databaseService: DatabaseManagerService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
up: string,
|
||||||
|
down: string,
|
||||||
|
databaseId: string
|
||||||
|
): Promise<Migration> {
|
||||||
|
const database = await this.databaseService.findById(databaseId);
|
||||||
|
|
||||||
|
if (!database) {
|
||||||
|
throw new Error("Database not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const migration = this.migrationRepository.create({
|
||||||
|
up,
|
||||||
|
down,
|
||||||
|
database,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.migrationRepository.save(migration);
|
||||||
|
}
|
||||||
|
|
||||||
|
async up(databaseId: string): Promise<Migration[]> {
|
||||||
|
const database = await this.databaseService.findById(databaseId);
|
||||||
|
|
||||||
|
if (!database) {
|
||||||
|
throw new Error("Database not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrations = await this.migrationRepository.find({
|
||||||
|
where: { database: { id: database.id }, appliedAt: null },
|
||||||
|
order: { createdAt: "ASC" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedMigrations: Migration[] = [];
|
||||||
|
|
||||||
|
for (const migration of migrations) {
|
||||||
|
try {
|
||||||
|
await this.databaseService.runQuery(database.id, migration.up);
|
||||||
|
migration.appliedAt = new Date();
|
||||||
|
await this.migrationRepository.save(migration);
|
||||||
|
completedMigrations.push(migration);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to apply migration ${migration.id}: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completedMigrations;
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(databaseId: string): Promise<Migration[]> {
|
||||||
|
const database = await this.databaseService.findById(databaseId);
|
||||||
|
|
||||||
|
if (!database) {
|
||||||
|
throw new Error("Database not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrations = await this.migrationRepository.find({
|
||||||
|
where: { database: { id: database.id }, appliedAt: Not(IsNull()) },
|
||||||
|
order: { appliedAt: "DESC" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const revertedMigrations: Migration[] = [];
|
||||||
|
|
||||||
|
for (const migration of migrations) {
|
||||||
|
try {
|
||||||
|
await this.databaseService.runQuery(database.id, migration.down);
|
||||||
|
migration.appliedAt = null;
|
||||||
|
await this.migrationRepository.save(migration);
|
||||||
|
revertedMigrations.push(migration);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to revert migration ${migration.id}: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return revertedMigrations;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import { Project } from "../../project/entities/project.entity";
|
|
||||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
|
|
||||||
import { MigrationTable } from "../migration.constants";
|
|
||||||
|
|
||||||
@Entity("migration")
|
|
||||||
export class Migration {
|
|
||||||
@PrimaryGeneratedColumn("uuid")
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@Column()
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
@Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" })
|
|
||||||
appliedAt: Date;
|
|
||||||
|
|
||||||
@Column({ type: "tinyint", default: 1 })
|
|
||||||
isApplied: number;
|
|
||||||
|
|
||||||
@Column({ type: "tinyint", default: 1 })
|
|
||||||
isValid: number;
|
|
||||||
|
|
||||||
@ManyToOne(() => Project, (project) => project.migrations)
|
|
||||||
project: Project;
|
|
||||||
|
|
||||||
@Column()
|
|
||||||
sql: string;
|
|
||||||
|
|
||||||
@Column({ type: "json", nullable: true })
|
|
||||||
data: MigrationTable[];
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
export type MigrationFieldType =
|
|
||||||
| "int"
|
|
||||||
| "float"
|
|
||||||
| "bigint"
|
|
||||||
| "boolean"
|
|
||||||
| "text"
|
|
||||||
| "uuid"
|
|
||||||
| "datetime";
|
|
||||||
|
|
||||||
export type MigrationField = {
|
|
||||||
type: MigrationFieldType;
|
|
||||||
isNullable: boolean;
|
|
||||||
isUnique: boolean;
|
|
||||||
default?: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MigrationRelationType =
|
|
||||||
| "one-to-many"
|
|
||||||
| "many-to-one"
|
|
||||||
| "many-to-many";
|
|
||||||
|
|
||||||
export type MigrationTable = {
|
|
||||||
name: string;
|
|
||||||
fields: Record<string, MigrationField>;
|
|
||||||
relations?: Record<string, { table: string; type: MigrationRelationType }>;
|
|
||||||
};
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import { Body, Controller, Post } from "@nestjs/common";
|
|
||||||
import { MigrationService } from "./migration.service";
|
|
||||||
import { MigrationTable } from "./migration.constants";
|
|
||||||
|
|
||||||
@Controller("migrations")
|
|
||||||
export class MigrationController {
|
|
||||||
constructor(private readonly migrationService: MigrationService) {}
|
|
||||||
|
|
||||||
@Post("create")
|
|
||||||
async createMigration(
|
|
||||||
@Body() body: { token: string; tables: MigrationTable[] }
|
|
||||||
) {
|
|
||||||
return await this.migrationService.create(body.tables, body.token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { forwardRef, Module } from "@nestjs/common";
|
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
|
||||||
import { Migration } from "./entities/migration.entity";
|
|
||||||
import { MigrationController } from "./migration.controller";
|
|
||||||
import { MigrationService } from "./migration.service";
|
|
||||||
import { ProjectModule } from "src/project/project.module";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
forwardRef(() => ProjectModule),
|
|
||||||
TypeOrmModule.forFeature([Migration]),
|
|
||||||
],
|
|
||||||
controllers: [MigrationController],
|
|
||||||
providers: [MigrationService],
|
|
||||||
})
|
|
||||||
export class MigrationModule {}
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
import { Inject, Injectable } from "@nestjs/common";
|
|
||||||
import { Repository } from "typeorm";
|
|
||||||
import { Migration } from "./entities/migration.entity";
|
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
|
||||||
import { MigrationTable } from "./migration.constants";
|
|
||||||
import { ProjectService } from "src/project/project.service";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MigrationService {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(Migration)
|
|
||||||
private readonly migrationRepository: Repository<Migration>,
|
|
||||||
@Inject(ProjectService)
|
|
||||||
private readonly projectService: ProjectService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
compareTable(
|
|
||||||
oldTable: MigrationTable,
|
|
||||||
newTable: MigrationTable
|
|
||||||
): MigrationTable {
|
|
||||||
const changes: MigrationTable = {
|
|
||||||
name: newTable.name,
|
|
||||||
fields: {},
|
|
||||||
relations: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const fieldName in newTable.fields) {
|
|
||||||
if (!oldTable.fields[fieldName]) {
|
|
||||||
changes.fields[fieldName] = newTable.fields[fieldName];
|
|
||||||
} else {
|
|
||||||
const oldField = oldTable.fields[fieldName];
|
|
||||||
const newField = newTable.fields[fieldName];
|
|
||||||
|
|
||||||
if (
|
|
||||||
oldField.type !== newField.type ||
|
|
||||||
oldField.isNullable !== newField.isNullable ||
|
|
||||||
oldField.isUnique !== newField.isUnique ||
|
|
||||||
oldField.default !== newField.default
|
|
||||||
) {
|
|
||||||
changes.fields[fieldName] = newField;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const relationName in newTable.relations) {
|
|
||||||
if (!oldTable.relations || !oldTable.relations[relationName]) {
|
|
||||||
changes.relations[relationName] = newTable.relations[relationName];
|
|
||||||
} else {
|
|
||||||
const oldRelation = oldTable.relations[relationName];
|
|
||||||
const newRelation = newTable.relations[relationName];
|
|
||||||
|
|
||||||
if (
|
|
||||||
oldRelation.table !== newRelation.table ||
|
|
||||||
oldRelation.type !== newRelation.type
|
|
||||||
) {
|
|
||||||
changes.relations[relationName] = newRelation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return changes;
|
|
||||||
}
|
|
||||||
|
|
||||||
generateSQL(changes: MigrationTable[]): string {
|
|
||||||
let sql = "";
|
|
||||||
|
|
||||||
for (const table of changes) {
|
|
||||||
if (table.fields && Object.keys(table.fields).length > 0) {
|
|
||||||
if (
|
|
||||||
Object.keys(table.fields).length === Object.keys(table.fields).length
|
|
||||||
) {
|
|
||||||
sql += `CREATE TABLE ${table.name} (\n`;
|
|
||||||
const fieldDefs = [];
|
|
||||||
for (const fieldName in table.fields) {
|
|
||||||
const field = table.fields[fieldName];
|
|
||||||
let fieldDef = ` ${fieldName} ${field.type.toUpperCase()}`;
|
|
||||||
if (!field.isNullable) {
|
|
||||||
fieldDef += " NOT NULL";
|
|
||||||
}
|
|
||||||
if (field.isUnique) {
|
|
||||||
fieldDef += " UNIQUE";
|
|
||||||
}
|
|
||||||
if (field.default !== undefined) {
|
|
||||||
fieldDef += ` DEFAULT ${field.default}`;
|
|
||||||
}
|
|
||||||
fieldDefs.push(fieldDef);
|
|
||||||
}
|
|
||||||
sql += fieldDefs.join(",\n");
|
|
||||||
sql += `\n);\n\n`;
|
|
||||||
} else {
|
|
||||||
for (const fieldName in table.fields) {
|
|
||||||
const field = table.fields[fieldName];
|
|
||||||
sql += `ALTER TABLE ${
|
|
||||||
table.name
|
|
||||||
} ADD COLUMN ${fieldName} ${field.type.toUpperCase()}`;
|
|
||||||
if (!field.isNullable) {
|
|
||||||
sql += " NOT NULL";
|
|
||||||
}
|
|
||||||
if (field.isUnique) {
|
|
||||||
sql += " UNIQUE";
|
|
||||||
}
|
|
||||||
if (field.default !== undefined) {
|
|
||||||
sql += ` DEFAULT ${field.default}`;
|
|
||||||
}
|
|
||||||
sql += `;\n`;
|
|
||||||
}
|
|
||||||
sql += `\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (table.relations && Object.keys(table.relations).length > 0) {
|
|
||||||
for (const relationName in table.relations) {
|
|
||||||
const relation = table.relations[relationName];
|
|
||||||
if (relation.type === "many-to-one") {
|
|
||||||
sql += `ALTER TABLE ${table.name} ADD COLUMN ${relationName}_id UUID;\n`;
|
|
||||||
sql += `ALTER TABLE ${table.name} ADD CONSTRAINT fk_${table.name}_${relationName} FOREIGN KEY (${relationName}_id) REFERENCES ${relation.table}(id);\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (relation.type === "one-to-many") {
|
|
||||||
sql += `ALTER TABLE ${relation.table} ADD COLUMN ${table.name}_id UUID;\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (relation.type === "many-to-many") {
|
|
||||||
const junctionTable = `${table.name}_${relation.table}`;
|
|
||||||
sql += `CREATE TABLE ${junctionTable} (\n`;
|
|
||||||
sql += ` ${table.name}_id UUID NOT NULL,\n`;
|
|
||||||
sql += ` ${relation.table}_id UUID NOT NULL,\n`;
|
|
||||||
sql += ` PRIMARY KEY (${table.name}_id, ${relation.table}_id),\n`;
|
|
||||||
sql += ` FOREIGN KEY (${table.name}_id) REFERENCES ${table.name}(id),\n`;
|
|
||||||
sql += ` FOREIGN KEY (${relation.table}_id) REFERENCES ${relation.table}(id)\n`;
|
|
||||||
sql += `);\n\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sql;
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(tables: MigrationTable[], token: string): Promise<Migration> {
|
|
||||||
const project = await this.projectService.findByToken(token);
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
throw new Error("Project not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const migrations = await this.migrationRepository.find({
|
|
||||||
where: {
|
|
||||||
project: { token },
|
|
||||||
isApplied: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const virtualTables = {};
|
|
||||||
for (const migration of migrations) {
|
|
||||||
for (const table of migration.data) {
|
|
||||||
if (!virtualTables[table.name]) {
|
|
||||||
virtualTables[table.name] = table;
|
|
||||||
} else {
|
|
||||||
virtualTables[table.name] = this.compareTable(
|
|
||||||
virtualTables[table.name],
|
|
||||||
table
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const changes: MigrationTable[] = [];
|
|
||||||
for (const table of tables) {
|
|
||||||
if (!virtualTables[table.name]) {
|
|
||||||
changes.push(table);
|
|
||||||
} else {
|
|
||||||
const tableChanges = this.compareTable(
|
|
||||||
virtualTables[table.name],
|
|
||||||
table
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
Object.keys(tableChanges.fields).length > 0 ||
|
|
||||||
(tableChanges.relations &&
|
|
||||||
Object.keys(tableChanges.relations).length > 0)
|
|
||||||
) {
|
|
||||||
changes.push(tableChanges);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changes.length === 0) {
|
|
||||||
throw new Error("No changes detected");
|
|
||||||
}
|
|
||||||
|
|
||||||
const migration = this.migrationRepository.create({
|
|
||||||
name: `Migration ${new Date().toISOString()}`,
|
|
||||||
project,
|
|
||||||
sql: this.generateSQL(changes),
|
|
||||||
data: changes,
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.migrationRepository.save(migration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +1,18 @@
|
|||||||
import { Migration } from "../../migration/entities/migration.entity";
|
|
||||||
import { Token } from "../../api/entities/token.entity";
|
import { Token } from "../../api/entities/token.entity";
|
||||||
import { Query } from "../../query/entities/query.entity";
|
import { Query } from "../../query/entities/query.entity";
|
||||||
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
OneToMany,
|
||||||
|
OneToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from "typeorm";
|
||||||
|
import { Database } from "src/databaseManager/entities/database.entity";
|
||||||
|
|
||||||
@Entity("project")
|
@Entity("project")
|
||||||
export class Project {
|
export class Project {
|
||||||
@PrimaryGeneratedColumn("uuid")
|
@PrimaryGeneratedColumn("uuid")
|
||||||
token: string;
|
id: string;
|
||||||
|
|
||||||
@Column({ type: "varchar", length: 255 })
|
@Column({ type: "varchar", length: 255 })
|
||||||
name: string;
|
name: string;
|
||||||
@ -14,8 +20,8 @@ export class Project {
|
|||||||
@OneToMany(() => Token, (token) => token.project)
|
@OneToMany(() => Token, (token) => token.project)
|
||||||
apiTokens: Token[];
|
apiTokens: Token[];
|
||||||
|
|
||||||
@OneToMany(() => Migration, (migration) => migration.project)
|
@OneToOne(() => Database, (database) => database.project)
|
||||||
migrations: Migration[];
|
database: Database;
|
||||||
|
|
||||||
@OneToMany(() => Query, (query) => query.project)
|
@OneToMany(() => Query, (query) => query.project)
|
||||||
queries: Query[];
|
queries: Query[];
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export class ProjectService {
|
|||||||
return this.projectRepository.save(project);
|
return this.projectRepository.save(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
findByToken(token: string) {
|
findById(id: string) {
|
||||||
return this.projectRepository.findOne({ where: { token } });
|
return this.projectRepository.findOne({ where: { id: id } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,9 +98,7 @@ export class QueryHandlerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createQuery(queryData: { projectToken: string; source: string }) {
|
async createQuery(queryData: { projectToken: string; source: string }) {
|
||||||
const project = await this.projectService.findByToken(
|
const project = await this.projectService.findById(queryData.projectToken);
|
||||||
queryData.projectToken
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error("Project not found");
|
throw new Error("Project not found");
|
||||||
|
|||||||
@ -1270,6 +1270,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.6:
|
|||||||
shebang-command "^2.0.0"
|
shebang-command "^2.0.0"
|
||||||
which "^2.0.1"
|
which "^2.0.1"
|
||||||
|
|
||||||
|
crypto@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/crypto/-/crypto-1.0.1.tgz#2af1b7cad8175d24c8a1b0778255794a21803037"
|
||||||
|
integrity sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==
|
||||||
|
|
||||||
dayjs@^1.11.13:
|
dayjs@^1.11.13:
|
||||||
version "1.11.18"
|
version "1.11.18"
|
||||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.18.tgz#835fa712aac52ab9dec8b1494098774ed7070a11"
|
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.18.tgz#835fa712aac52ab9dec8b1494098774ed7070a11"
|
||||||
|
|||||||
Reference in New Issue
Block a user