From 2f848137ed5b42847889db620fe7a733769955c1 Mon Sep 17 00:00:00 2001 From: lborv Date: Fri, 26 Sep 2025 21:55:57 +0300 Subject: [PATCH] feat: implement Migration module with controller, service, and entity, including migration creation logic --- src/app/app.module.ts | 2 + src/migration/entities/migration.entity.ts | 10 +- src/migration/migration.controller.ts | 15 +- src/migration/migration.module.ts | 8 +- src/migration/migration.service.ts | 199 +++++++++++++++++++-- src/migrations/1758912793124-Migrations.ts | 59 ++++++ src/project/entities/project.entity.ts | 2 +- tests/base/createMigration.ts | 22 +++ tests/functions/createMigration.ts | 15 ++ 9 files changed, 311 insertions(+), 21 deletions(-) create mode 100644 src/migrations/1758912793124-Migrations.ts create mode 100644 tests/base/createMigration.ts create mode 100644 tests/functions/createMigration.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index b498237..ecc9a2c 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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: [], diff --git a/src/migration/entities/migration.entity.ts b/src/migration/entities/migration.entity.ts index 5076631..a64ebb5 100644 --- a/src/migration/entities/migration.entity.ts +++ b/src/migration/entities/migration.entity.ts @@ -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; + @Column() + sql: string; + + @Column({ type: "json", nullable: true }) + data: MigrationTable[]; } diff --git a/src/migration/migration.controller.ts b/src/migration/migration.controller.ts index 6959a17..a5860a1 100644 --- a/src/migration/migration.controller.ts +++ b/src/migration/migration.controller.ts @@ -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); + } +} diff --git a/src/migration/migration.module.ts b/src/migration/migration.module.ts index 3725257..8c038d2 100644 --- a/src/migration/migration.module.ts +++ b/src/migration/migration.module.ts @@ -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], }) diff --git a/src/migration/migration.service.ts b/src/migration/migration.service.ts index 5869626..3ff06d6 100644 --- a/src/migration/migration.service.ts +++ b/src/migration/migration.service.ts @@ -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 - @InjectRepository(Project) - private readonly projectRepository: Repository + private readonly migrationRepository: Repository, + @Inject(ProjectService) + private readonly projectService: ProjectService ) {} - create(tables: MigrationTable[], projectId: string): Promise { - 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 { + 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); } } diff --git a/src/migrations/1758912793124-Migrations.ts b/src/migrations/1758912793124-Migrations.ts new file mode 100644 index 0000000..f96fefb --- /dev/null +++ b/src/migrations/1758912793124-Migrations.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migrations1758912793124 implements MigrationInterface { + name = "Migrations1758912793124"; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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\``); + } +} diff --git a/src/project/entities/project.entity.ts b/src/project/entities/project.entity.ts index a62dd00..4b75518 100644 --- a/src/project/entities/project.entity.ts +++ b/src/project/entities/project.entity.ts @@ -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"; diff --git a/tests/base/createMigration.ts b/tests/base/createMigration.ts new file mode 100644 index 0000000..50471d2 --- /dev/null +++ b/tests/base/createMigration.ts @@ -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); + } +})(); diff --git a/tests/functions/createMigration.ts b/tests/functions/createMigration.ts new file mode 100644 index 0000000..f85db6c --- /dev/null +++ b/tests/functions/createMigration.ts @@ -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); + } +};