import { ModelClass, JSONSchema, RelationMappings, Model } from 'objection'; import { BaseModel } from './base.model'; export interface FieldDefinition { apiName: string; label: string; type: string; isRequired?: boolean; isUnique?: boolean; referenceObject?: string; defaultValue?: string; } export interface RelationDefinition { name: string; type: 'belongsTo' | 'hasMany' | 'hasManyThrough'; targetObjectApiName: string; fromColumn: string; toColumn: string; } export interface ObjectMetadata { apiName: string; tableName: string; fields: FieldDefinition[]; relations?: RelationDefinition[]; } export class DynamicModelFactory { /** * Get relation name from lookup field API name * Converts "ownerId" -> "owner", "customFieldId" -> "customfield" */ static getRelationName(lookupFieldApiName: string): string { return lookupFieldApiName.replace(/Id$/, '').toLowerCase(); } /** * Create a dynamic model class from object metadata * @param meta Object metadata * @param getModel Function to retrieve model classes from registry */ static createModel( meta: ObjectMetadata, getModel?: (apiName: string) => ModelClass, ): ModelClass { const { tableName, fields, apiName, relations = [] } = meta; // Build JSON schema properties const properties: Record = { id: { type: 'string' }, tenantId: { type: 'string' }, ownerId: { type: 'string' }, name: { type: 'string' }, created_at: { type: 'string', format: 'date-time' }, updated_at: { type: 'string', format: 'date-time' }, }; // Don't require id or tenantId - they'll be set automatically const required: string[] = []; // Add custom fields for (const field of fields) { properties[field.apiName] = this.fieldToJsonSchema(field); // Only mark as required if explicitly required AND not a system field const systemFields = ['id', 'tenantId', 'ownerId', 'name', 'created_at', 'updated_at']; if (field.isRequired && !systemFields.includes(field.apiName)) { required.push(field.apiName); } } // Build relation mappings from lookup fields const lookupFields = fields.filter(f => f.type === 'LOOKUP' && f.referenceObject); // Store lookup fields metadata for later use const lookupFieldsInfo = lookupFields.map(f => ({ apiName: f.apiName, relationName: DynamicModelFactory.getRelationName(f.apiName), referenceObject: f.referenceObject, targetTable: this.getTableName(f.referenceObject), })); // Create the dynamic model class extending BaseModel class DynamicModel extends BaseModel { static tableName = tableName; static objectApiName = apiName; static lookupFields = lookupFieldsInfo; static get relationMappings(): RelationMappings { const mappings: RelationMappings = {}; // Build relation mappings from lookup fields for (const lookupInfo of lookupFieldsInfo) { // Use getModel function if provided, otherwise use string reference let modelClass: any = lookupInfo.referenceObject; if (getModel) { const resolvedModel = getModel(lookupInfo.referenceObject); // Only use resolved model if it exists, otherwise skip this relation // It will be resolved later when the model is registered if (resolvedModel) { modelClass = resolvedModel; } else { // Skip this relation if model not found yet continue; } } mappings[lookupInfo.relationName] = { relation: Model.BelongsToOneRelation, modelClass, join: { from: `${tableName}.${lookupInfo.apiName}`, to: `${lookupInfo.targetTable}.id`, }, }; } return mappings; } static get jsonSchema() { return { type: 'object', required, properties, }; } } return DynamicModel as any; } /** * Convert a field definition to JSON schema property */ private static fieldToJsonSchema(field: FieldDefinition): Record { switch (field.type.toUpperCase()) { case 'TEXT': case 'STRING': case 'EMAIL': case 'URL': case 'PHONE': case 'PICKLIST': case 'MULTI_PICKLIST': return { type: 'string', ...(field.isUnique && { uniqueItems: true }), }; case 'LONG_TEXT': return { type: 'string' }; case 'NUMBER': case 'DECIMAL': case 'CURRENCY': case 'PERCENT': return { type: 'number', ...(field.isUnique && { uniqueItems: true }), }; case 'INTEGER': return { type: 'integer', ...(field.isUnique && { uniqueItems: true }), }; case 'BOOLEAN': return { type: 'boolean', default: false }; case 'DATE': return { type: 'string', format: 'date' }; case 'DATE_TIME': return { type: 'string', format: 'date-time' }; case 'LOOKUP': case 'BELONGS_TO': return { type: 'string' }; default: return { type: 'string' }; } } /** * Get table name from object API name */ private static getTableName(objectApiName: string): string { // Convert PascalCase/camelCase to snake_case and pluralize const snakeCase = objectApiName .replace(/([A-Z])/g, '_$1') .toLowerCase() .replace(/^_/, ''); return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`; } }