UNPKG

@cmmv/repository

Version:

Repository module using TypeORM for CMMV

619 lines (610 loc) 24.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.RepositoryMigration = void 0; const core_1 = require("@cmmv/core"); class RepositoryMigration extends core_1.Singleton { /** * Generate a TypeORM migration file based on changes between contract versions * @param currentContract The current state of the contract * @param updatedContract The updated state of the contract (null if contract is being removed) * @returns Path to the generated migration file */ static async generateMigration(currentContract, updatedContract) { if (currentContract && !updatedContract) { const tableName = this.getTableName(currentContract); const migrationFile = await this.createDropTableMigrationFile(currentContract); return migrationFile; } if (!updatedContract || updatedContract.options?.moduleContract) return null; if (!currentContract) { const changes = { hasChanges: true, tableName: this.getTableName(updatedContract), addedFields: updatedContract.fields || [], removedFields: [], modifiedFields: [], addedIndexes: updatedContract.indexs || [], removedIndexes: [], modifiedIndexes: [], }; const migrationFile = await this.createMigrationFile(updatedContract, changes, true); return migrationFile; } const changes = this.detectChanges(currentContract, updatedContract); if (!changes.hasChanges) return null; const migrationFile = await this.createMigrationFile(updatedContract, changes); return migrationFile; } /** * Detect changes between two versions of a contract * @param currentContract The current state of the contract * @param updatedContract The updated state of the contract * @returns The changes to create the migration for */ static detectChanges(currentContract, updatedContract) { const changes = { hasChanges: false, tableName: this.getTableName(updatedContract), addedFields: [], removedFields: [], modifiedFields: [], addedIndexes: [], removedIndexes: [], modifiedIndexes: [], }; const currentFields = currentContract.fields || []; const updatedFields = updatedContract.fields || []; const currentFieldsMap = new Map(currentFields.map((field) => [field.propertyKey, field])); const updatedFieldsMap = new Map(updatedFields.map((field) => [field.propertyKey, field])); for (const [key, field] of updatedFieldsMap.entries()) { if (!currentFieldsMap.has(key)) { changes.addedFields.push(field); changes.hasChanges = true; } } for (const [key, field] of currentFieldsMap.entries()) { if (!updatedFieldsMap.has(key)) { changes.removedFields.push(field); changes.hasChanges = true; } } for (const [key, updatedField] of updatedFieldsMap.entries()) { if (currentFieldsMap.has(key)) { const currentField = currentFieldsMap.get(key); if (this.fieldHasChanged(currentField, updatedField)) { changes.modifiedFields.push({ old: currentField, new: updatedField, }); changes.hasChanges = true; } } } const currentIndexes = currentContract.indexs || []; const updatedIndexes = updatedContract.indexs || []; const currentIndexesMap = new Map(currentIndexes.map((index) => [index.name, index])); const updatedIndexesMap = new Map(updatedIndexes.map((index) => [index.name, index])); for (const [name, index] of updatedIndexesMap.entries()) { if (!currentIndexesMap.has(name)) { changes.addedIndexes.push(index); changes.hasChanges = true; } } for (const [name, index] of currentIndexesMap.entries()) { if (!updatedIndexesMap.has(name)) { changes.removedIndexes.push(index); changes.hasChanges = true; } } for (const [name, updatedIndex] of updatedIndexesMap.entries()) { if (currentIndexesMap.has(name)) { const currentIndex = currentIndexesMap.get(name); if (this.indexHasChanged(currentIndex, updatedIndex)) { changes.modifiedIndexes.push({ old: currentIndex, new: updatedIndex, }); changes.hasChanges = true; } } } return changes; } /** * Check if a field has been modified * @param oldField The old field * @param newField The new field * @returns True if the field has been modified, false otherwise */ static fieldHasChanged(oldField, newField) { if (oldField.protoType !== newField.protoType) return true; if (oldField.protoRepeated !== newField.protoRepeated) return true; if (oldField.nullable !== newField.nullable) return true; if (oldField.unique !== newField.unique) return true; if (JSON.stringify(oldField.defaultValue) !== JSON.stringify(newField.defaultValue)) return true; const oldValidations = JSON.stringify(oldField.validations || []); const newValidations = JSON.stringify(newField.validations || []); if (oldValidations !== newValidations) return true; return false; } /** * Check if an index has been modified * @param oldIndex The old index * @param newIndex The new index * @returns True if the index has been modified, false otherwise */ static indexHasChanged(oldIndex, newIndex) { const oldFields = oldIndex.fields || []; const newFields = newIndex.fields || []; if (oldFields.length !== newFields.length) return true; for (let i = 0; i < oldFields.length; i++) if (oldFields[i] !== newFields[i]) return true; const oldOptions = oldIndex.options || {}; const newOptions = newIndex.options || {}; if (oldOptions.unique !== newOptions.unique) return true; if (oldOptions.spatial !== newOptions.spatial) return true; if (oldOptions.fulltext !== newOptions.fulltext) return true; if (oldOptions.parser !== newOptions.parser) return true; if (oldOptions.where !== newOptions.where) return true; if (oldOptions.sparse !== newOptions.sparse) return true; if (oldOptions.background !== newOptions.background) return true; return false; } /** * Create a migration file in the `/src/migrations` directory * @param contract The contract to create the migration for * @param changes The changes to create the migration for * @param isNewContract Whether this is a new contract * @returns The path to the created migration file */ static async createMigrationFile(contract, changes, isNewContract = false) { const fs = require('fs'); const path = require('path'); const timestamp = new Date().getTime(); const contractName = contract.contractName.replace(/Contract$/, ''); const migrationName = `${timestamp}-${contractName}`; const migrationDir = path.resolve(process.cwd(), 'src/migrations'); if (!fs.existsSync(migrationDir)) fs.mkdirSync(migrationDir, { recursive: true }); const migrationPath = path.join(migrationDir, `${migrationName}.ts`); const migrationContent = isNewContract ? this.generateCreateTableMigration(migrationName, changes, timestamp, contractName) : this.generateMigrationContent(migrationName, changes, timestamp, contractName); fs.writeFileSync(migrationPath, migrationContent); return migrationPath; } /** * Generate the content of the migration file * @param className The name of the migration class * @param changes The changes to create the migration for * @returns The content of the migration file */ static generateMigrationContent(className, changes, timestamp, contractName) { const { tableName, addedFields, removedFields, modifiedFields, addedIndexes, removedIndexes, modifiedIndexes, } = changes; let upMethodContent = ''; let downMethodContent = ''; if (addedFields.length > 0) { upMethodContent += this.generateAddColumnsCode(tableName, addedFields); downMethodContent += this.generateDropColumnsCode(tableName, addedFields); } if (removedFields.length > 0) { upMethodContent += this.generateDropColumnsCode(tableName, removedFields); downMethodContent += this.generateAddColumnsCode(tableName, removedFields); } if (modifiedFields.length > 0) { upMethodContent += this.generateAlterColumnsCode(tableName, modifiedFields, 'up'); downMethodContent += this.generateAlterColumnsCode(tableName, modifiedFields, 'down'); } if (addedIndexes.length > 0) { upMethodContent += this.generateCreateIndexesCode(tableName, addedIndexes); downMethodContent += this.generateDropIndexesCode(tableName, addedIndexes); } if (removedIndexes.length > 0) { upMethodContent += this.generateDropIndexesCode(tableName, removedIndexes); downMethodContent += this.generateCreateIndexesCode(tableName, removedIndexes); } if (modifiedIndexes.length > 0) { upMethodContent += this.generateModifyIndexesCode(tableName, modifiedIndexes, 'up'); downMethodContent += this.generateModifyIndexesCode(tableName, modifiedIndexes, 'down'); } return `import { MigrationInterface, QueryRunner, Table, TableColumn, TableIndex } from "typeorm"; export class Update${contractName}${timestamp} implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise<void> { ${this.indentCode(upMethodContent, 8)} } public async down(queryRunner: QueryRunner): Promise<void> { ${this.indentCode(downMethodContent, 8)} } } `; } /** * Generate TypeORM code to add columns * @param tableName The name of the table to modify * @param fields The fields to add * @returns The TypeORM code to add columns */ static generateAddColumnsCode(tableName, fields) { if (fields.length === 0) return ''; let code = `// Add columns\n`; for (const field of fields) { const columnDef = this.generateColumnDefinition(field); code += `await queryRunner.addColumn("${tableName}", ${columnDef});\n`; } return code + '\n'; } /** * Generate TypeORM code to drop columns * @param tableName The name of the table to modify * @param fields The fields to drop * @returns The TypeORM code to drop columns */ static generateDropColumnsCode(tableName, fields) { if (fields.length === 0) return ''; let code = `// Drop columns\n`; for (const field of fields) code += `await queryRunner.dropColumn("${tableName}", "${field.propertyKey}");\n`; return code + '\n'; } /** * Generate TypeORM code to alter columns * @param tableName The name of the table to modify * @param modifiedFields The fields to alter * @param direction The direction of the migration * @returns The TypeORM code to alter columns */ static generateAlterColumnsCode(tableName, modifiedFields, direction) { if (modifiedFields.length === 0) return ''; let code = `// Alter columns\n`; for (const modField of modifiedFields) { const field = direction === 'up' ? modField.new : modField.old; const columnDef = this.generateColumnDefinition(field); code += `await queryRunner.changeColumn("${tableName}", "${field.propertyKey}", ${columnDef});\n`; } return code + '\n'; } /** * Generate TypeORM code to create indexes * @param tableName The name of the table to modify * @param indexes The indexes to create * @returns The TypeORM code to create indexes */ static generateCreateIndexesCode(tableName, indexes) { if (indexes.length === 0) return ''; let code = `// Create indexes\n`; for (const index of indexes) { const indexDef = this.generateIndexDefinition(index); code += `await queryRunner.createIndex("${tableName}", ${indexDef});\n`; } return code + '\n'; } /** * Generate TypeORM code to drop indexes * @param tableName The name of the table to modify * @param indexes The indexes to drop * @returns The TypeORM code to drop indexes */ static generateDropIndexesCode(tableName, indexes) { if (indexes.length === 0) return ''; let code = `// Drop indexes\n`; for (const index of indexes) code += `await queryRunner.dropIndex("${tableName}", "${index.name}");\n`; return code + '\n'; } /** * Generate TypeORM code to modify indexes * @param tableName The name of the table to modify * @param modifiedIndexes The indexes to modify * @param direction The direction of the migration * @returns The TypeORM code to modify indexes */ static generateModifyIndexesCode(tableName, modifiedIndexes, direction) { if (modifiedIndexes.length === 0) return ''; let code = `// Modify indexes\n`; for (const modIndex of modifiedIndexes) { const oldIndex = modIndex.old; const newIndex = direction === 'up' ? modIndex.new : modIndex.old; code += `await queryRunner.dropIndex("${tableName}", "${oldIndex.name}");\n`; const indexDef = this.generateIndexDefinition(newIndex); code += `await queryRunner.createIndex("${tableName}", ${indexDef});\n`; } return code + '\n'; } /** * Generate a TypeORM column definition from a field object * @param field The field to generate the definition for * @returns The TypeORM column definition */ static generateColumnDefinition(field) { const typeMapping = { string: 'varchar', text: 'text', boolean: 'boolean', bool: 'boolean', int32: 'int', int64: 'bigint', float: 'float', double: 'double', number: 'float', date: 'date', timestamp: 'timestamp', time: 'time', json: 'json', uuid: 'uuid', bigint: 'bigint', simpleArray: 'simple-array', }; const type = typeMapping[field.protoType] || 'varchar'; const isArray = field.protoRepeated; return `new TableColumn({ name: "${field.propertyKey}", type: "${type}", isArray: ${isArray}, isNullable: ${field.nullable !== false ? 'true' : 'false'}, isUnique: ${field.unique === true ? 'true' : 'false'}, default: ${field.defaultValue ? JSON.stringify(field.defaultValue) : 'undefined'} })`; } /** * Generate a TypeORM index definition from an index object * @param index The index to generate the definition for * @returns The TypeORM index definition */ static generateIndexDefinition(index) { const fields = index.fields || []; const options = index.options || {}; return `new TableIndex({ name: "${index.name}", columnNames: ${JSON.stringify(fields)}, isUnique: ${options.unique ? 'true' : 'false'}, isSpatial: ${options.spatial ? 'true' : 'false'}, isFulltext: ${options.fulltext ? 'true' : 'false'}, where: ${options.where ? `"${options.where}"` : 'undefined'} })`; } /** * Get the database table name from a contract * @param contract The contract to get the table name from * @returns The database table name */ static getTableName(contract) { if (contract.options?.databaseSchemaName) return contract.options.databaseSchemaName; const baseName = contract.controllerName || contract.contractName.replace(/Contract$/, ''); return this.toSnakeCase(baseName); } /** * Convert a string to snake_case * @param str The string to convert * @returns The snake_case string */ static toSnakeCase(str) { return str .replace(/([A-Z])/g, '_$1') .replace(/^_/, '') .toLowerCase(); } /** * Indent multiline code by a specified number of spaces * @param code The code to indent * @param spaces The number of spaces to indent * @returns The indented code */ static indentCode(code, spaces) { const indent = ' '.repeat(spaces); return code .split('\n') .map((line) => (line ? indent + line : line)) .join('\n'); } /** * Generate a migration file for creating a new table * @param className The class name of the migration * @param changes The changes to apply * @returns The migration file content */ static generateCreateTableMigration(className, changes, timestamp, contractName) { const { tableName, addedFields, addedIndexes } = changes; const columnDefs = addedFields .map((field) => { const typeMapping = { string: 'varchar', text: 'text', boolean: 'boolean', bool: 'boolean', int32: 'int', int64: 'bigint', float: 'float', double: 'double', number: 'float', date: 'date', timestamp: 'timestamp', time: 'time', json: 'json', uuid: 'uuid', bigint: 'bigint', simpleArray: 'simple-array', }; const type = typeMapping[field.protoType] || 'varchar'; const isArray = field.protoRepeated; return `{ name: "${field.propertyKey}", type: "${type}", isArray: ${isArray}, isNullable: ${field.nullable !== false ? 'true' : 'false'}, isUnique: ${field.unique === true ? 'true' : 'false'}, default: ${field.defaultValue ? JSON.stringify(field.defaultValue) : 'undefined'} }`; }) .join(',\n '); const indexDefs = addedIndexes .map((index) => { const fields = index.fields || []; const options = index.options || {}; return `{ name: "${index.name}", columnNames: ${JSON.stringify(fields)}, isUnique: ${options.unique ? 'true' : 'false'}, isSpatial: ${options.spatial ? 'true' : 'false'}, isFulltext: ${options.fulltext ? 'true' : 'false'}, where: ${options.where ? `"${options.where}"` : 'undefined'} }`; }) .join(',\n '); const upContent = `// Create new table const table = new Table({ name: "${tableName}", columns: [ ${columnDefs} ]${addedIndexes.length > 0 ? `, indices: [ ${indexDefs} ]` : ''} }); await queryRunner.createTable(table);`; const downContent = `// Drop table await queryRunner.dropTable("${tableName}");`; return `import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class Create${contractName}${timestamp} implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise<void> { ${this.indentCode(upContent, 8)} } public async down(queryRunner: QueryRunner): Promise<void> { ${this.indentCode(downContent, 8)} } } `; } /** * Create a migration file specifically for dropping a table * @param contract The contract whose table is being dropped * @returns Path to the generated migration file */ static async createDropTableMigrationFile(contract) { const fs = require('fs'); const path = require('path'); const timestamp = new Date().getTime(); const contractName = contract.contractName.replace(/Contract$/, ''); const tableName = this.getTableName(contract); const migrationName = `${timestamp}-${contractName}`; const migrationDir = path.resolve(process.cwd(), 'src/migrations'); if (!fs.existsSync(migrationDir)) fs.mkdirSync(migrationDir, { recursive: true }); const migrationPath = path.join(migrationDir, `${migrationName}.ts`); const migrationContent = this.generateDropTableMigration(migrationName, tableName, contract); fs.writeFileSync(migrationPath, migrationContent); return migrationPath; } /** * Generate a migration file specifically for dropping a table * @param className The name of the migration class * @param tableName The name of the table to drop * @param contract The original contract for recreating the table in down migration * @returns The contents of the migration file */ static generateDropTableMigration(className, tableName, contract) { const fields = contract.fields || []; const indexes = contract.indexs || []; const upContent = `// Drop table await queryRunner.dropTable("${tableName}");`; const columnDefs = fields .map((field) => { const typeMapping = { string: 'varchar', text: 'text', boolean: 'boolean', bool: 'boolean', int32: 'int', int64: 'bigint', float: 'float', double: 'double', number: 'float', date: 'date', timestamp: 'timestamp', time: 'time', json: 'json', uuid: 'uuid', bigint: 'bigint', simpleArray: 'simple-array', }; const type = typeMapping[field.protoType] || 'varchar'; const isArray = field.protoRepeated; return `{ name: "${field.propertyKey}", type: "${type}", isArray: ${isArray}, isNullable: ${field.nullable !== false ? 'true' : 'false'}, isUnique: ${field.unique === true ? 'true' : 'false'}, default: ${field.defaultValue ? JSON.stringify(field.defaultValue) : 'undefined'} }`; }) .join(',\n '); // Generate index definitions const indexDefs = indexes .map((index) => { const indexFields = index.fields || []; const options = index.options || {}; return `{ name: "${index.name}", columnNames: ${JSON.stringify(indexFields)}, isUnique: ${options.unique ? 'true' : 'false'}, isSpatial: ${options.spatial ? 'true' : 'false'}, isFulltext: ${options.fulltext ? 'true' : 'false'}, where: ${options.where ? `"${options.where}"` : 'undefined'} }`; }) .join(',\n '); // Down migration recreates the table const downContent = `// Recreate the dropped table const table = new Table({ name: "${tableName}", columns: [ ${columnDefs} ]${indexes.length > 0 ? `, indices: [ ${indexDefs} ]` : ''} }); await queryRunner.createTable(table);`; return `import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class Drop${className} implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise<void> { ${this.indentCode(upContent, 8)} } public async down(queryRunner: QueryRunner): Promise<void> { ${this.indentCode(downContent, 8)} } } `; } } exports.RepositoryMigration = RepositoryMigration;