WIP - using objection base model to handle objects operations

This commit is contained in:
Francisco Gaona
2025-12-24 20:18:43 +01:00
parent e4f1ba96ad
commit 4520f94b69
15 changed files with 2325 additions and 16 deletions

View File

@@ -0,0 +1,35 @@
import { Model } from 'objection';
/**
* Base model for all dynamic and system models
* Provides common functionality for all objects
*/
export class BaseModel extends Model {
// Common fields
id?: string;
tenantId?: string;
ownerId?: string;
name?: string;
created_at?: Date;
updated_at?: Date;
// Hook to set system-managed fields
$beforeInsert() {
// created_at and updated_at are handled by the database
// ownerId should be set by the controller/service
}
$beforeUpdate() {
// updated_at is handled by the database
}
/**
* Get the API name for this object
* Override in subclasses
*/
static get objectApiName(): string {
return 'BaseModel';
}
}

View File

@@ -0,0 +1,162 @@
import { randomUUID } from 'crypto';
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 {
/**
* Create a dynamic model class from object metadata
*/
static createModel(meta: ObjectMetadata): ModelClass<any> {
const { tableName, fields, apiName, relations = [] } = meta;
// Build JSON schema properties
const properties: Record<string, any> = {
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' },
};
const required: string[] = ['id', 'tenantId'];
// 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
const relationMappings: RelationMappings = {};
for (const rel of relations) {
// Relations are resolved dynamically, skipping for now
// Will be handled by ModelRegistry.getModel()
}
// Create the dynamic model class extending Model directly
class DynamicModel extends Model {
id?: string;
tenantId?: string;
ownerId?: string;
name?: string;
created_at?: string;
updated_at?: string;
static tableName = tableName;
static objectApiName = apiName;
static relationMappings = relationMappings;
static get jsonSchema() {
return {
type: 'object',
required,
properties,
};
}
async $beforeInsert() {
if (!this.id) {
this.id = randomUUID();
}
if (!this.created_at) {
this.created_at = new Date().toISOString();
}
if (!this.updated_at) {
this.updated_at = new Date().toISOString();
}
}
async $beforeUpdate() {
this.updated_at = new Date().toISOString();
}
}
return DynamicModel as any;
}
/**
* Convert a field definition to JSON schema property
*/
private static fieldToJsonSchema(field: FieldDefinition): Record<string, any> {
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' };
}
}
}

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import { ModelClass } from 'objection';
import { BaseModel } from './base.model';
import { DynamicModelFactory, ObjectMetadata } from './dynamic-model.factory';
/**
* Registry to store and retrieve dynamic models
* One registry per tenant
*/
@Injectable()
export class ModelRegistry {
private registry = new Map<string, ModelClass<BaseModel>>();
/**
* Register a model in the registry
*/
registerModel(apiName: string, modelClass: ModelClass<BaseModel>): void {
this.registry.set(apiName, modelClass);
}
/**
* Get a model from the registry
*/
getModel(apiName: string): ModelClass<BaseModel> {
const model = this.registry.get(apiName);
if (!model) {
throw new Error(`Model for ${apiName} not found in registry`);
}
return model;
}
/**
* Check if a model exists in the registry
*/
hasModel(apiName: string): boolean {
return this.registry.has(apiName);
}
/**
* Create and register a model from metadata
*/
createAndRegisterModel(
metadata: ObjectMetadata,
): ModelClass<BaseModel> {
const model = DynamicModelFactory.createModel(metadata);
this.registerModel(metadata.apiName, model);
return model;
}
/**
* Get all registered model names
*/
getAllModelNames(): string[] {
return Array.from(this.registry.keys());
}
/**
* Clear the registry (useful for testing)
*/
clear(): void {
this.registry.clear();
}
}

View File

@@ -0,0 +1,81 @@
import { Injectable, Logger } from '@nestjs/common';
import { Knex } from 'knex';
import { ModelClass } from 'objection';
import { BaseModel } from './base.model';
import { ModelRegistry } from './model.registry';
import { ObjectMetadata } from './dynamic-model.factory';
import { TenantDatabaseService } from '../../tenant/tenant-database.service';
/**
* Service to manage dynamic models for a specific tenant
*/
@Injectable()
export class ModelService {
private readonly logger = new Logger(ModelService.name);
private tenantRegistries = new Map<string, ModelRegistry>();
constructor(private tenantDbService: TenantDatabaseService) {}
/**
* Get or create a registry for a tenant
*/
getTenantRegistry(tenantId: string): ModelRegistry {
if (!this.tenantRegistries.has(tenantId)) {
this.tenantRegistries.set(tenantId, new ModelRegistry());
}
return this.tenantRegistries.get(tenantId)!;
}
/**
* Create and register a model for a tenant
*/
async createModelForObject(
tenantId: string,
objectMetadata: ObjectMetadata,
): Promise<ModelClass<BaseModel>> {
const registry = this.getTenantRegistry(tenantId);
const model = registry.createAndRegisterModel(objectMetadata);
this.logger.log(
`Registered model for ${objectMetadata.apiName} in tenant ${tenantId}`,
);
return model;
}
/**
* Get a model for a tenant and object
*/
getModel(tenantId: string, objectApiName: string): ModelClass<BaseModel> {
const registry = this.getTenantRegistry(tenantId);
return registry.getModel(objectApiName);
}
/**
* Get a bound model (with knex connection) for a tenant and object
*/
async getBoundModel(
tenantId: string,
objectApiName: string,
): Promise<ModelClass<BaseModel>> {
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const model = this.getModel(tenantId, objectApiName);
return model.bindKnex(knex);
}
/**
* Check if a model exists for a tenant
*/
hasModel(tenantId: string, objectApiName: string): boolean {
const registry = this.getTenantRegistry(tenantId);
return registry.hasModel(objectApiName);
}
/**
* Get all model names for a tenant
*/
getAllModelNames(tenantId: string): string[] {
const registry = this.getTenantRegistry(tenantId);
return registry.getAllModelNames();
}
}