feat: implement Migration module with controller, service, and entity, including migration creation logic

This commit is contained in:
lborv
2025-09-26 21:55:57 +03:00
parent 6d88c21305
commit 2f848137ed
9 changed files with 311 additions and 21 deletions

View File

@ -5,6 +5,7 @@ import { QueryModule } from "src/query/query.module";
import { ProjectModule } from "src/project/project.module";
import { RedisModule } from "src/redis/redis.module";
import { DatabaseModule } from "src/database/database.module";
import { MigrationModule } from "src/migration/migration.module";
@Module({
imports: [
@ -14,6 +15,7 @@ import { DatabaseModule } from "src/database/database.module";
ProjectModule,
QueryModule,
TestModule,
MigrationModule,
],
controllers: [],
providers: [],

View File

@ -1,5 +1,6 @@
import { Project } from "src/project/entities/project.entity";
import { Project } from "../../project/entities/project.entity";
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import { MigrationTable } from "../migration.constants";
@Entity("migration")
export class Migration {
@ -21,6 +22,9 @@ export class Migration {
@ManyToOne(() => Project, (project) => project.migrations)
project: Project;
@Column({ type: "jsonb", nullable: true })
data: Record<string, any>;
@Column()
sql: string;
@Column({ type: "json", nullable: true })
data: MigrationTable[];
}

View File

@ -1,4 +1,15 @@
import { Controller } from "@nestjs/common";
import { Body, Controller, Post } from "@nestjs/common";
import { MigrationService } from "./migration.service";
import { MigrationTable } from "./migration.constants";
@Controller("migrations")
export class MigrationController {}
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);
}
}

View File

@ -1,11 +1,15 @@
import { Module } from "@nestjs/common";
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: [TypeOrmModule.forFeature([Migration])],
imports: [
forwardRef(() => ProjectModule),
TypeOrmModule.forFeature([Migration]),
],
controllers: [MigrationController],
providers: [MigrationService],
})

View File

@ -1,27 +1,200 @@
import { Injectable } from "@nestjs/common";
import { In, Repository } from "typeorm";
import { Inject, Injectable } from "@nestjs/common";
import { Repository } from "typeorm";
import { Migration } from "./entities/migration.entity";
import { InjectRepository } from "@nestjs/typeorm";
import { Project } from "src/project/entities/project.entity";
import { MigrationTable } from "./migration.constants";
import { ProjectService } from "src/project/project.service";
@Injectable()
export class MigrationService {
constructor(
@InjectRepository(Migration)
private readonly migrationRepository: Repository<Migration>
@InjectRepository(Project)
private readonly projectRepository: Repository<Project>
private readonly migrationRepository: Repository<Migration>,
@Inject(ProjectService)
private readonly projectService: ProjectService
) {}
create(tables: MigrationTable[], projectId: string): Promise<Migration> {
const project = this.projectRepository.findOne({
where: { token: projectId },
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 migrations = this.migrationRepository.find({
where: {
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);
}
}

View File

@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Migrations1758912793124 implements MigrationInterface {
name = "Migrations1758912793124";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE \`migration\` (\`id\` varchar(36) NOT NULL, \`name\` varchar(255) NOT NULL, \`appliedAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP(), \`isApplied\` tinyint NOT NULL DEFAULT '1', \`isValid\` tinyint NOT NULL DEFAULT '1', \`sql\` varchar(255) NOT NULL, \`data\` json NULL, \`projectToken\` varchar(36) NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`
);
await queryRunner.query(
`CREATE TABLE \`query_modules_module\` (\`queryId\` varchar(36) NOT NULL, \`moduleId\` varchar(36) NOT NULL, INDEX \`IDX_12121324c524e12538de4948ce\` (\`queryId\`), INDEX \`IDX_7fb42dbc85874aafbd4bfb1c10\` (\`moduleId\`), PRIMARY KEY (\`queryId\`, \`moduleId\`)) ENGINE=InnoDB`
);
await queryRunner.query(
`ALTER TABLE \`module\` DROP COLUMN \`isInjectable\``
);
await queryRunner.query(
`ALTER TABLE \`plugin\` ADD \`queryId\` varchar(36) NULL`
);
await queryRunner.query(
`ALTER TABLE \`migration\` ADD CONSTRAINT \`FK_d3d093f32ce7c968b02fc6bce65\` FOREIGN KEY (\`projectToken\`) REFERENCES \`project\`(\`token\`) ON DELETE NO ACTION ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE \`plugin\` ADD CONSTRAINT \`FK_5162d18c3653d35ff4d104dd940\` FOREIGN KEY (\`queryId\`) REFERENCES \`query\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE \`query_modules_module\` ADD CONSTRAINT \`FK_12121324c524e12538de4948cee\` FOREIGN KEY (\`queryId\`) REFERENCES \`query\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`
);
await queryRunner.query(
`ALTER TABLE \`query_modules_module\` ADD CONSTRAINT \`FK_7fb42dbc85874aafbd4bfb1c101\` FOREIGN KEY (\`moduleId\`) REFERENCES \`module\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE \`query_modules_module\` DROP FOREIGN KEY \`FK_7fb42dbc85874aafbd4bfb1c101\``
);
await queryRunner.query(
`ALTER TABLE \`query_modules_module\` DROP FOREIGN KEY \`FK_12121324c524e12538de4948cee\``
);
await queryRunner.query(
`ALTER TABLE \`plugin\` DROP FOREIGN KEY \`FK_5162d18c3653d35ff4d104dd940\``
);
await queryRunner.query(
`ALTER TABLE \`migration\` DROP FOREIGN KEY \`FK_d3d093f32ce7c968b02fc6bce65\``
);
await queryRunner.query(`ALTER TABLE \`plugin\` DROP COLUMN \`queryId\``);
await queryRunner.query(
`ALTER TABLE \`module\` ADD \`isInjectable\` tinyint NOT NULL DEFAULT 1`
);
await queryRunner.query(
`DROP INDEX \`IDX_7fb42dbc85874aafbd4bfb1c10\` ON \`query_modules_module\``
);
await queryRunner.query(
`DROP INDEX \`IDX_12121324c524e12538de4948ce\` ON \`query_modules_module\``
);
await queryRunner.query(`DROP TABLE \`query_modules_module\``);
await queryRunner.query(`DROP TABLE \`migration\``);
}
}

View File

@ -1,4 +1,4 @@
import { Migration } from "src/migration/entities/migration.entity";
import { Migration } from "../../migration/entities/migration.entity";
import { Token } from "../../api/entities/token.entity";
import { Query } from "../../query/entities/query.entity";
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";

View File

@ -0,0 +1,22 @@
import createMigration from "../functions/createMigration";
import createProject from "../functions/createProject";
(async () => {
try {
const project = await createProject("Test Project");
const result_1 = await createMigration(project.token, [
{
name: "users",
fields: {
name: { type: "string", isNullable: false },
age: { type: "int", isNullable: true },
},
},
]);
console.log("Migration 1:", result_1.data);
} catch (error) {
console.error("Error during test execution:", error);
}
})();

View File

@ -0,0 +1,15 @@
import axios from "axios";
import { config } from "../config";
export default async (token: string, tables: any) => {
try {
const response = await axios.post(`${config.url}/migrations/create`, {
token,
tables,
});
return response.data;
} catch (error) {
console.error("Error in creating migration:", error);
}
};