import { Injectable, Logger } from '@nestjs/common'; import { Knex } from 'knex'; import { ObjectDefinition } from '../models/object-definition.model'; import { FieldDefinition } from '../models/field-definition.model'; @Injectable() export class SchemaManagementService { private readonly logger = new Logger(SchemaManagementService.name); /** * Create a physical table for an object definition */ async createObjectTable( knex: Knex, objectDefinition: ObjectDefinition, fields: FieldDefinition[], ) { const tableName = this.getTableName(objectDefinition.apiName); // Check if table already exists const exists = await knex.schema.hasTable(tableName); if (exists) { throw new Error(`Table ${tableName} already exists`); } await knex.schema.createTable(tableName, (table) => { // Standard fields table.uuid('id').primary().defaultTo(knex.raw('(UUID())')); table.timestamps(true, true); // Custom fields from field definitions for (const field of fields) { this.addFieldColumn(table, field); } }); this.logger.log(`Created table: ${tableName}`); } /** * Add a new field to an existing object table */ async addFieldToTable( knex: Knex, objectApiName: string, field: FieldDefinition, ) { const tableName = this.getTableName(objectApiName); await knex.schema.alterTable(tableName, (table) => { this.addFieldColumn(table, field); }); this.logger.log(`Added field ${field.apiName} to table ${tableName}`); } /** * Remove a field from an existing object table */ async removeFieldFromTable( knex: Knex, objectApiName: string, fieldApiName: string, ) { const tableName = this.getTableName(objectApiName); await knex.schema.alterTable(tableName, (table) => { table.dropColumn(fieldApiName); }); this.logger.log(`Removed field ${fieldApiName} from table ${tableName}`); } /** * Alter a field in an existing object table * Handles safe updates like changing NOT NULL or constraints * Warns about potentially destructive operations */ async alterFieldInTable( knex: Knex, objectApiName: string, fieldApiName: string, field: FieldDefinition, options?: { skipTypeChange?: boolean; // Skip if type change would lose data }, ) { const tableName = this.getTableName(objectApiName); const skipTypeChange = options?.skipTypeChange ?? true; await knex.schema.alterTable(tableName, (table) => { // Drop the existing column and recreate with new definition // Note: This approach works for metadata changes, but type changes may need data migration table.dropColumn(fieldApiName); }); // Recreate the column with new definition await knex.schema.alterTable(tableName, (table) => { this.addFieldColumn(table, field); }); this.logger.log(`Altered field ${fieldApiName} in table ${tableName}`); } /** * Drop an object table */ async dropObjectTable(knex: Knex, objectApiName: string) { const tableName = this.getTableName(objectApiName); await knex.schema.dropTableIfExists(tableName); this.logger.log(`Dropped table: ${tableName}`); } /** * Add a field column to a table builder */ private addFieldColumn( table: Knex.CreateTableBuilder | Knex.AlterTableBuilder, field: FieldDefinition, ) { const columnName = field.apiName; let column: Knex.ColumnBuilder; switch (field.type) { // Text types case 'String': case 'TEXT': case 'EMAIL': case 'PHONE': case 'URL': column = table.string(columnName, field.length || 255); break; case 'Text': case 'LONG_TEXT': column = table.text(columnName); break; case 'PICKLIST': case 'MULTI_PICKLIST': column = table.string(columnName, 255); break; // Numeric types case 'Number': case 'NUMBER': case 'CURRENCY': case 'PERCENT': if (field.scale && field.scale > 0) { column = table.decimal( columnName, field.precision || 10, field.scale, ); } else { column = table.integer(columnName); } break; case 'Boolean': case 'BOOLEAN': column = table.boolean(columnName).defaultTo(false); break; // Date types case 'Date': case 'DATE': column = table.date(columnName); break; case 'DateTime': case 'DATE_TIME': column = table.datetime(columnName); break; case 'TIME': column = table.time(columnName); break; // Relationship types case 'Reference': case 'LOOKUP': column = table.uuid(columnName); if (field.referenceObject) { const refTableName = this.getTableName(field.referenceObject); column.references('id').inTable(refTableName).onDelete('SET NULL'); } break; // Email (legacy) case 'Email': column = table.string(columnName, 255); break; // Phone (legacy) case 'Phone': column = table.string(columnName, 50); break; // Url (legacy) case 'Url': column = table.string(columnName, 255); break; // File types case 'FILE': case 'IMAGE': column = table.text(columnName); // Store file path or URL break; // JSON case 'Json': case 'JSON': column = table.json(columnName); break; default: throw new Error(`Unsupported field type: ${field.type}`); } if (field.isRequired) { column.notNullable(); } else { column.nullable(); } if (field.isUnique) { column.unique(); } if (field.defaultValue) { column.defaultTo(field.defaultValue); } return column; } /** * Convert object API name to table name (convert to snake_case, pluralize) */ private getTableName(apiName: string): string { // Convert PascalCase to snake_case const snakeCase = apiName .replace(/([A-Z])/g, '_$1') .toLowerCase() .replace(/^_/, ''); // Simple pluralization (append 's' if not already plural) // In production, use a proper pluralization library return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`; } /** * Validate field definition before creating column */ validateFieldDefinition(field: FieldDefinition) { if (!field.apiName || !field.label || !field.type) { throw new Error('Field must have apiName, label, and type'); } // Validate field name (alphanumeric + underscore, starts with letter) if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(field.apiName)) { throw new Error(`Invalid field name: ${field.apiName}`); } // Validate reference field has referenceObject if (field.type === 'Reference' && !field.referenceObject) { throw new Error('Reference field must specify referenceObject'); } // Validate numeric fields if (field.type === 'Number') { if (field.scale && field.scale > 0 && !field.precision) { throw new Error('Decimal fields must specify precision'); } } return true; } }