307 lines
7.9 KiB
TypeScript
307 lines
7.9 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { Knex } from 'knex';
|
|
|
|
export interface CustomMigrationRecord {
|
|
id: string;
|
|
tenantId: string;
|
|
name: string;
|
|
description: string;
|
|
type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom';
|
|
sql: string;
|
|
status: 'pending' | 'executed' | 'failed';
|
|
executedAt?: Date;
|
|
error?: string;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
|
|
@Injectable()
|
|
export class CustomMigrationService {
|
|
private readonly logger = new Logger(CustomMigrationService.name);
|
|
|
|
/**
|
|
* Generate SQL to create a table with standard fields
|
|
*/
|
|
generateCreateTableSQL(
|
|
tableName: string,
|
|
fields: {
|
|
apiName: string;
|
|
type: string;
|
|
isRequired?: boolean;
|
|
isUnique?: boolean;
|
|
defaultValue?: string;
|
|
}[] = [],
|
|
): string {
|
|
// Start with standard fields
|
|
const columns: string[] = [
|
|
'`id` VARCHAR(36) PRIMARY KEY',
|
|
'`ownerId` VARCHAR(36)',
|
|
'`name` VARCHAR(255)',
|
|
'`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP',
|
|
'`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
|
|
];
|
|
|
|
// Add custom fields
|
|
for (const field of fields) {
|
|
const column = this.fieldToColumn(field);
|
|
columns.push(column);
|
|
}
|
|
|
|
// Add foreign key and index for ownerId
|
|
columns.push('INDEX `idx_owner` (`ownerId`)');
|
|
|
|
return `CREATE TABLE IF NOT EXISTS \`${tableName}\` (
|
|
${columns.join(',\n ')}
|
|
)`;
|
|
}
|
|
|
|
/**
|
|
* Convert field definition to SQL column definition
|
|
*/
|
|
private fieldToColumn(field: {
|
|
apiName: string;
|
|
type: string;
|
|
isRequired?: boolean;
|
|
isUnique?: boolean;
|
|
defaultValue?: string;
|
|
}): string {
|
|
const columnName = field.apiName;
|
|
let columnDef = `\`${columnName}\``;
|
|
|
|
// Map field types to SQL types
|
|
switch (field.type.toUpperCase()) {
|
|
case 'TEXT':
|
|
case 'STRING':
|
|
columnDef += ' VARCHAR(255)';
|
|
break;
|
|
case 'LONG_TEXT':
|
|
columnDef += ' LONGTEXT';
|
|
break;
|
|
case 'NUMBER':
|
|
case 'DECIMAL':
|
|
columnDef += ' DECIMAL(18, 2)';
|
|
break;
|
|
case 'INTEGER':
|
|
columnDef += ' INT';
|
|
break;
|
|
case 'BOOLEAN':
|
|
columnDef += ' BOOLEAN DEFAULT FALSE';
|
|
break;
|
|
case 'DATE':
|
|
columnDef += ' DATE';
|
|
break;
|
|
case 'DATE_TIME':
|
|
columnDef += ' DATETIME';
|
|
break;
|
|
case 'EMAIL':
|
|
columnDef += ' VARCHAR(255)';
|
|
break;
|
|
case 'URL':
|
|
columnDef += ' VARCHAR(2048)';
|
|
break;
|
|
case 'PHONE':
|
|
columnDef += ' VARCHAR(20)';
|
|
break;
|
|
case 'CURRENCY':
|
|
columnDef += ' DECIMAL(18, 2)';
|
|
break;
|
|
case 'PERCENT':
|
|
columnDef += ' DECIMAL(5, 2)';
|
|
break;
|
|
case 'PICKLIST':
|
|
case 'MULTI_PICKLIST':
|
|
columnDef += ' VARCHAR(255)';
|
|
break;
|
|
case 'LOOKUP':
|
|
case 'BELONGS_TO':
|
|
columnDef += ' VARCHAR(36)';
|
|
break;
|
|
default:
|
|
columnDef += ' VARCHAR(255)';
|
|
}
|
|
|
|
// Add constraints
|
|
if (field.isRequired) {
|
|
columnDef += ' NOT NULL';
|
|
} else {
|
|
columnDef += ' NULL';
|
|
}
|
|
|
|
if (field.isUnique) {
|
|
columnDef += ' UNIQUE';
|
|
}
|
|
|
|
if (field.defaultValue !== undefined && field.defaultValue !== null) {
|
|
columnDef += ` DEFAULT '${field.defaultValue}'`;
|
|
}
|
|
|
|
return columnDef;
|
|
}
|
|
|
|
/**
|
|
* Create a custom migration record in the database
|
|
*/
|
|
async createMigrationRecord(
|
|
tenantKnex: Knex,
|
|
data: {
|
|
tenantId: string;
|
|
name: string;
|
|
description: string;
|
|
type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom';
|
|
sql: string;
|
|
},
|
|
): Promise<CustomMigrationRecord> {
|
|
// Ensure custom_migrations table exists
|
|
await this.ensureMigrationsTable(tenantKnex);
|
|
|
|
const id = require('crypto').randomUUID();
|
|
const now = new Date();
|
|
|
|
await tenantKnex('custom_migrations').insert({
|
|
id,
|
|
tenantId: data.tenantId,
|
|
name: data.name,
|
|
description: data.description,
|
|
type: data.type,
|
|
sql: data.sql,
|
|
status: 'pending',
|
|
created_at: now,
|
|
updated_at: now,
|
|
});
|
|
|
|
return tenantKnex('custom_migrations').where({ id }).first();
|
|
}
|
|
|
|
/**
|
|
* Execute a pending migration and update its status
|
|
*/
|
|
async executeMigration(
|
|
tenantKnex: Knex,
|
|
migrationId: string,
|
|
): Promise<CustomMigrationRecord> {
|
|
try {
|
|
// Get the migration record
|
|
const migration = await tenantKnex('custom_migrations')
|
|
.where({ id: migrationId })
|
|
.first();
|
|
|
|
if (!migration) {
|
|
throw new Error(`Migration ${migrationId} not found`);
|
|
}
|
|
|
|
if (migration.status === 'executed') {
|
|
this.logger.log(`Migration ${migrationId} already executed`);
|
|
return migration;
|
|
}
|
|
|
|
// Execute the SQL
|
|
this.logger.log(`Executing migration: ${migration.name}`);
|
|
await tenantKnex.raw(migration.sql);
|
|
|
|
// Update status
|
|
const now = new Date();
|
|
await tenantKnex('custom_migrations')
|
|
.where({ id: migrationId })
|
|
.update({
|
|
status: 'executed',
|
|
executedAt: now,
|
|
updated_at: now,
|
|
});
|
|
|
|
this.logger.log(`Migration ${migration.name} executed successfully`);
|
|
return tenantKnex('custom_migrations').where({ id: migrationId }).first();
|
|
} catch (error) {
|
|
this.logger.error(`Failed to execute migration ${migrationId}:`, error);
|
|
|
|
// Update status with error
|
|
const now = new Date();
|
|
await tenantKnex('custom_migrations')
|
|
.where({ id: migrationId })
|
|
.update({
|
|
status: 'failed',
|
|
error: error.message,
|
|
updated_at: now,
|
|
});
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create and execute a migration in one step
|
|
*/
|
|
async createAndExecuteMigration(
|
|
tenantKnex: Knex,
|
|
tenantId: string,
|
|
data: {
|
|
name: string;
|
|
description: string;
|
|
type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom';
|
|
sql: string;
|
|
},
|
|
): Promise<CustomMigrationRecord> {
|
|
// Create the migration record
|
|
const migration = await this.createMigrationRecord(tenantKnex, {
|
|
tenantId,
|
|
...data,
|
|
});
|
|
|
|
// Execute it immediately
|
|
return this.executeMigration(tenantKnex, migration.id);
|
|
}
|
|
|
|
/**
|
|
* Ensure the custom_migrations table exists in the tenant database
|
|
*/
|
|
private async ensureMigrationsTable(tenantKnex: Knex): Promise<void> {
|
|
const hasTable = await tenantKnex.schema.hasTable('custom_migrations');
|
|
|
|
if (!hasTable) {
|
|
await tenantKnex.schema.createTable('custom_migrations', (table) => {
|
|
table.uuid('id').primary();
|
|
table.uuid('tenantId').notNullable();
|
|
table.string('name', 255).notNullable();
|
|
table.text('description');
|
|
table.enum('type', ['create_table', 'add_column', 'alter_column', 'add_index', 'drop_table', 'custom']).notNullable();
|
|
table.text('sql').notNullable();
|
|
table.enum('status', ['pending', 'executed', 'failed']).defaultTo('pending');
|
|
table.timestamp('executedAt').nullable();
|
|
table.text('error').nullable();
|
|
table.timestamps(true, true);
|
|
|
|
table.index(['tenantId']);
|
|
table.index(['status']);
|
|
table.index(['created_at']);
|
|
});
|
|
|
|
this.logger.log('Created custom_migrations table');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all migrations for a tenant
|
|
*/
|
|
async getMigrations(
|
|
tenantKnex: Knex,
|
|
tenantId: string,
|
|
filter?: {
|
|
status?: 'pending' | 'executed' | 'failed';
|
|
type?: string;
|
|
},
|
|
): Promise<CustomMigrationRecord[]> {
|
|
await this.ensureMigrationsTable(tenantKnex);
|
|
|
|
let query = tenantKnex('custom_migrations').where({ tenantId });
|
|
|
|
if (filter?.status) {
|
|
query = query.where({ status: filter.status });
|
|
}
|
|
|
|
if (filter?.type) {
|
|
query = query.where({ type: filter.type });
|
|
}
|
|
|
|
return query.orderBy('created_at', 'asc');
|
|
}
|
|
}
|