284 lines
7.2 KiB
TypeScript
284 lines
7.2 KiB
TypeScript
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;
|
|
}
|
|
}
|